Files
NetMesh/electron/bridges/autoUpdateBridge.test.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

879 lines
28 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const Module = require("node:module");
const tempDirBridge = require("./tempDirBridge.cjs");
const BRIDGE_PATH = require.resolve("./autoUpdateBridge.cjs");
const WINDOW_MANAGER_PATH = require.resolve("./windowManager.cjs");
const GLOBAL_SHORTCUT_PATH = require.resolve("./globalShortcutBridge.cjs");
const DIRTY_EDITOR_GUARD_PATH = require.resolve("./dirtyEditorGuard.cjs");
// electron-updater pulls in native/electron-only code, so it can't be required
// in a plain `node --test` process. We intercept the bare `electron-updater`
// specifier and the bridge's lazy sibling requires at load time and hand back
// lightweight fakes. The patch stays installed for the whole test body because
// the bridge resolves electron-updater / windowManager / globalShortcutBridge
// lazily at IPC-invoke time, not at module load.
const ELECTRON_UPDATER_ID = "electron-updater";
/**
* Run `fn` with electron-updater, windowManager, and globalShortcutBridge
* replaced by the supplied fakes. The fakes are also exposed to `fn` so it can
* assert on their interactions. Restores Module._load and the bridge cache on
* exit so tests stay isolated.
*/
async function withMocks({ autoUpdater, autoUpdaterExports, windowManager, globalShortcutBridge, dirtyEditorGuard, browserWindows } = {}, fn) {
const fakeAutoUpdater = autoUpdater || {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {},
};
const fakeWindowManager = windowManager || {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const fakeGlobalShortcut = globalShortcutBridge || {
cleanupCount: 0,
cleanup() {
this.cleanupCount += 1;
},
};
// Default: no dirty-editor guard override. The bridge requires the real
// dirtyEditorGuard.cjs (harmless — it only resolves ipcMain lazily, and the
// install handler only reaches it when there's a reachable main window). Most
// tests don't supply a main window, so the guard is never invoked.
const fakeDirtyEditorGuard = dirtyEditorGuard;
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === ELECTRON_UPDATER_ID) {
// autoUpdaterExports lets a test simulate a broken electron-updater
// (e.g. {} with no autoUpdater) so getAutoUpdater() resolves to null.
return autoUpdaterExports !== undefined
? autoUpdaterExports
: { autoUpdater: fakeAutoUpdater };
}
if (parent && parent.filename === BRIDGE_PATH) {
const resolved = path.resolve(path.dirname(BRIDGE_PATH), request);
const withExt = resolved.endsWith(".cjs") ? resolved : `${resolved}.cjs`;
if (withExt === WINDOW_MANAGER_PATH) return fakeWindowManager;
if (withExt === GLOBAL_SHORTCUT_PATH) return fakeGlobalShortcut;
if (withExt === DIRTY_EDITOR_GUARD_PATH && fakeDirtyEditorGuard) {
return fakeDirtyEditorGuard;
}
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[BRIDGE_PATH];
try {
const bridge = require("./autoUpdateBridge.cjs");
// Provide a minimal electronModule so readAutoUpdatePreference and
// broadcastToAllWindows don't throw. getPath returns a throwaway dir; the
// pref read falls back to its default when the file is absent.
const fakeApp = {
getPath: () => path.join("/", "tmp", "nc-autoupdate-test"),
getVersion: () => "1.1.17",
};
bridge.init({
electronModule: {
app: fakeApp,
// browserWindows lets a test observe broadcastToAllWindows (used by the
// needs-save notice). Defaults to none.
BrowserWindow: { getAllWindows: () => browserWindows || [] },
},
});
// Await so the Module._load patch stays installed for the *entire* test
// body, including after the now-async install handler yields on its first
// `await` (otherwise the lazy windowManager/dirtyEditorGuard requires would
// resolve the real modules once the finally below restored Module._load).
return await fn({ bridge, fakeAutoUpdater, fakeWindowManager, fakeGlobalShortcut });
} finally {
Module._load = originalLoad;
delete require.cache[BRIDGE_PATH];
}
}
/**
* A fake BrowserWindow for broadcastToAllWindows() (used by the needs-save
* notice). Records every channel sent to its webContents so a test can assert
* whether netcatty:update:needs-save was broadcast.
*/
function makeBroadcastWindow() {
const sentChannels = [];
return {
sentChannels,
isDestroyed() {
return false;
},
webContents: {
send(channel) {
sentChannels.push(channel);
},
},
};
}
/**
* Build a fake windowManager that also exposes a main window whose webContents
* the install handler can query for dirty editors. `sentChannels` records every
* webContents.send() on the *main window* (i.e. the dirty-editor query), kept
* separate from broadcast windows so tests can distinguish the two.
*/
function makeWindowManagerWithMainWindow() {
const sentChannels = [];
const webContents = {
send(channel) {
sentChannels.push(channel);
},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
};
return {
calls: [],
sentChannels,
webContents,
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
getMainWindow() {
return {
webContents,
isDestroyed() {
return false;
},
};
},
getMainWindows() {
return [this.getMainWindow()];
},
};
}
function makeWindowManagerWithMainWindows(count, options = {}) {
const windows = Array.from({ length: count }, (_unused, index) => {
const sentChannels = [];
const webContents = {
id: index + 1,
sentChannels,
send(channel) {
sentChannels.push(channel);
},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
};
return {
webContents,
isDestroyed() {
return false;
},
};
});
return {
calls: [],
windows,
appContentWindows: options.appContentWindows || windows,
dirtyEditorWindows: options.dirtyEditorWindows || windows,
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
getMainWindow() {
return windows[0] || null;
},
getMainWindows() {
return windows;
},
getAppContentWindows() {
return this.appContentWindows;
},
getDirtyEditorWindows() {
return this.dirtyEditorWindows;
},
};
}
async function withLinuxPackageEnvironment({ packageType, appImage }, fn) {
const packageDir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "auto-update-test-"));
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const resourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, "resourcesPath");
const previousAppImage = process.env.APPIMAGE;
try {
if (packageType) {
fs.writeFileSync(path.join(packageDir, "package-type"), `${packageType}\n`, "utf8");
}
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Object.defineProperty(process, "resourcesPath", { value: packageDir, configurable: true });
if (appImage) {
process.env.APPIMAGE = path.join(packageDir, "Netcatty.AppImage");
} else {
delete process.env.APPIMAGE;
}
return await fn();
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
} else {
delete process.platform;
}
if (resourcesPathDescriptor) {
Object.defineProperty(process, "resourcesPath", resourcesPathDescriptor);
} else {
delete process.resourcesPath;
}
if (previousAppImage === undefined) {
delete process.env.APPIMAGE;
} else {
process.env.APPIMAGE = previousAppImage;
}
fs.rmSync(packageDir, { recursive: true, force: true });
}
}
/**
* Minimal ipcMain stand-in that captures the handlers the bridge registers so a
* test can invoke a single channel directly.
*/
function makeIpcMain() {
const handlers = new Map();
return {
handle(channel, handler) {
handlers.set(channel, handler);
},
on() {},
invoke(channel, ...args) {
const handler = handlers.get(channel);
if (!handler) throw new Error(`No handler registered for ${channel}`);
return handler({}, ...args);
},
has(channel) {
return handlers.has(channel);
},
};
}
test("recognizes packaged Linux FPM formats as auto-update capable", async () => {
for (const packageType of ["deb", "rpm", "pacman"]) {
await withLinuxPackageEnvironment({ packageType }, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), true, packageType);
});
});
}
});
test("allows the update check to reach electron-updater for a packaged Linux deb", async () => {
let checkCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
checkForUpdates() {
checkCalls += 1;
return Promise.resolve({
updateInfo: {
version: "1.1.18",
releaseNotes: "",
releaseDate: null,
},
});
},
};
await withLinuxPackageEnvironment({ packageType: "deb" }, async () => {
await withMocks({ autoUpdater }, async ({ bridge, fakeAutoUpdater }) => {
fakeAutoUpdater.autoDownload = false;
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
const result = await ipcMain.invoke("netcatty:update:check");
assert.equal(checkCalls, 1);
assert.equal(result.supported, true);
assert.equal(result.available, true);
assert.equal(result.version, "1.1.18");
});
});
});
test("keeps the manual-update fallback for an unmarked Linux package", async () => {
await withLinuxPackageEnvironment({}, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), false);
});
});
});
test("keeps AppImage auto-update support on Linux", async () => {
await withLinuxPackageEnvironment({ appImage: true }, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), true);
});
});
});
test("install handler marks quitting-for-update before quitAndInstall", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall(isSilent, isForceRunAfter) {
order.push("quitAndInstall");
autoUpdater._installArgs = [isSilent, isForceRunAfter];
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
order.push("setQuittingForUpdate");
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// The flag must be set with `true`...
assert.deepEqual(fakeWindowManager.calls, [true]);
// ...and it must happen BEFORE quitAndInstall fires app.quit(), otherwise the
// close-to-tray / before-quit guards would already be racing the quit (#1215).
assert.equal(order[0], "setQuittingForUpdate");
assert.ok(order.indexOf("setQuittingForUpdate") < order.indexOf("quitAndInstall"));
// Only macOS needs the tray destroyed before quitAndInstall; other
// platforms clean it up from the normal will-quit handler.
assert.equal(fakeGlobalShortcut.cleanupCount, process.platform === "darwin" ? 1 : 0);
assert.equal(order.includes("quitAndInstall"), true);
});
});
test("install handler is a no-op when the updater fails to load", async () => {
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return false;
},
};
// electron-updater exports no `autoUpdater` => getAutoUpdater() returns null,
// so the handler must return early WITHOUT committing the app to a quit. Doing
// so otherwise would leave isQuitting=true and break close-to-tray even though
// no install actually started.
await withMocks({ autoUpdaterExports: {}, windowManager: fakeWindowManager }, async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(fakeWindowManager.calls, []);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
});
});
test("install handler rolls back quitting-for-update when quitAndInstall throws", async () => {
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
throw new Error("boom");
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// First set true (commit), then reset to false on the synchronous throw so
// the app doesn't get stuck bypassing close-to-tray / the quit guard (#1215).
assert.deepEqual(fakeWindowManager.calls, [true, false]);
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
});
});
test("install handler reports package-manager failures and restores the app state", async () => {
const listeners = new Map();
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on(event, listener) {
listeners.set(event, listener);
},
quitAndInstall() {
listeners.get("error")(new Error("authorization cancelled"));
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const broadcastWindow = makeBroadcastWindow();
await withLinuxPackageEnvironment({ packageType: "deb" }, async () => {
await withMocks({
autoUpdater,
windowManager: fakeWindowManager,
browserWindows: [broadcastWindow],
}, async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
listeners.get("update-available")({ version: "1.1.18" });
listeners.get("update-downloaded")();
assert.equal((await ipcMain.invoke("netcatty:update:getStatus")).status, "ready");
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(fakeWindowManager.calls, [true, false]);
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
assert.deepEqual(await ipcMain.invoke("netcatty:update:getStatus"), {
status: "error",
percent: 100,
error: "authorization cancelled",
version: "1.1.18",
isChecking: false,
});
assert.equal(broadcastWindow.sentChannels.includes("netcatty:update:error"), true);
});
});
});
test("install handler ignores concurrent install requests", async () => {
let installCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
installCalls += 1;
},
};
const fakeWindowManager = makeWindowManagerWithMainWindow();
const dirtyResolvers = [];
const fakeDirtyEditorGuard = {
queryDirtyEditors() {
return new Promise((resolve) => dirtyResolvers.push(resolve));
},
};
const originalSetTimeout = global.setTimeout;
let watchdogFn = null;
global.setTimeout = (fn) => {
watchdogFn = fn;
return { unref() {} };
};
try {
await withMocks({
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
}, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
const firstInstall = ipcMain.invoke("netcatty:update:install");
const secondInstall = ipcMain.invoke("netcatty:update:install");
assert.equal(dirtyResolvers.length, 2);
dirtyResolvers.forEach((resolve) => resolve(false));
await Promise.all([firstInstall, secondInstall]);
assert.equal(installCalls, 1);
assert.deepEqual(fakeWindowManager.calls, [true]);
watchdogFn?.();
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
});
} finally {
global.setTimeout = originalSetTimeout;
}
});
test("cancelPendingInstall releases the install guard for an immediate retry", async () => {
let installCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
installCalls += 1;
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
global.setTimeout = () => ({ unref() {} });
global.clearTimeout = () => {};
try {
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.equal(installCalls, 1);
assert.deepEqual(fakeWindowManager.calls, [true]);
// Simulate main.cjs cancelling the quit after a dirty-editor result.
bridge.cancelPendingInstall();
assert.deepEqual(fakeWindowManager.calls, [true, false]);
await ipcMain.invoke("netcatty:update:install");
assert.equal(installCalls, 2);
assert.deepEqual(fakeWindowManager.calls, [true, false, true]);
});
} finally {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
}
});
test("install handler watchdog clears quitting-for-update if the app never quits", async () => {
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
// Returns without ever quitting the app (simulates a Squirrel follow-up
// failure / stale download where app.quit() is never reached).
quitAndInstall() {},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
// Capture the watchdog timer instead of waiting for the real delay.
const originalSetTimeout = global.setTimeout;
let watchdogFn = null;
global.setTimeout = (fn) => {
watchdogFn = fn;
// Return a fake timer handle with an unref() no-op so the bridge can call it.
return { unref() {} };
};
try {
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// Committed to quit, watchdog scheduled but not yet fired.
assert.deepEqual(fakeWindowManager.calls, [true]);
assert.equal(typeof watchdogFn, "function");
// Fire the watchdog — the app is still alive, so it must clear the flag.
watchdogFn();
assert.deepEqual(fakeWindowManager.calls, [true, false]);
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
});
} finally {
global.setTimeout = originalSetTimeout;
}
});
// ---------------------------------------------------------------------------
// #1215 P1: the install handler must check for unsaved editors BEFORE
// committing to a quit. On macOS quitAndInstall() closes the window first and
// only then fires before-quit, so the before-quit dirty guard can run after the
// window is gone and silently drop unsaved SFTP edits. Checking up front (while
// the renderer is alive) is the fix.
// ---------------------------------------------------------------------------
test("install handler aborts and notifies when the renderer reports dirty editors", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
const fakeWindowManager = makeWindowManagerWithMainWindow();
const originalSetQuitting = fakeWindowManager.setQuittingForUpdate;
fakeWindowManager.setQuittingForUpdate = function (value) {
order.push("setQuittingForUpdate");
originalSetQuitting.call(this, value);
};
// queryDirtyEditors reports unsaved work.
let queriedWebContents = null;
const fakeDirtyEditorGuard = {
queryDirtyEditors(webContents) {
order.push("queryDirtyEditors");
queriedWebContents = webContents;
return Promise.resolve(true);
},
};
// Two windows (e.g. main + settings) so we can assert the needs-save notice is
// broadcast to BOTH, not just the queried main window (#1215 review).
const win1 = makeBroadcastWindow();
const win2 = makeBroadcastWindow();
await withMocks(
{
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
browserWindows: [win1, win2],
},
async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// Dirty editors → the install must be fully aborted:
// - no quitAndInstall, no setQuittingForUpdate, no tray cleanup
assert.equal(order.includes("quitAndInstall"), false);
assert.equal(order.includes("setQuittingForUpdate"), false);
assert.deepEqual(fakeWindowManager.calls, []);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
// - every window is told to prompt the user to save (broadcast needs-save)
assert.equal(win1.sentChannels.includes("netcatty:update:needs-save"), true);
assert.equal(win2.sentChannels.includes("netcatty:update:needs-save"), true);
// - the dirty check ran first, against the main window's webContents
assert.equal(order[0], "queryDirtyEditors");
assert.equal(queriedWebContents, fakeWindowManager.webContents);
},
);
});
test("install handler checks every registered dirty-editor window before installing", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
const fakeWindowManager = makeWindowManagerWithMainWindows(1);
const peerWebContents = {
id: 2,
sentChannels: [],
send(channel) {
this.sentChannels.push(channel);
},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
};
const peerWindow = {
webContents: peerWebContents,
isDestroyed() {
return false;
},
};
const lifecycleOnlyWindow = {
webContents: {
id: 3,
isDestroyed: () => false,
isCrashed: () => false,
},
isDestroyed: () => false,
};
fakeWindowManager.appContentWindows = [
fakeWindowManager.windows[0],
peerWindow,
lifecycleOnlyWindow,
];
fakeWindowManager.dirtyEditorWindows = [fakeWindowManager.windows[0], peerWindow];
const queriedWebContents = [];
const fakeDirtyEditorGuard = {
queryDirtyEditors(webContents) {
order.push(`queryDirtyEditors:${webContents.id}`);
queriedWebContents.push(webContents);
return Promise.resolve(webContents.id === 2);
},
};
const win = makeBroadcastWindow();
await withMocks(
{
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
browserWindows: [win],
},
async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(queriedWebContents, fakeWindowManager.dirtyEditorWindows.map((window) => window.webContents));
assert.equal(order.includes("quitAndInstall"), false);
assert.deepEqual(fakeWindowManager.calls, []);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
assert.equal(win.sentChannels.includes("netcatty:update:needs-save"), true);
},
);
});
test("install handler proceeds to quitAndInstall when there are no dirty editors", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
const fakeWindowManager = makeWindowManagerWithMainWindow();
const originalSetQuitting = fakeWindowManager.setQuittingForUpdate;
fakeWindowManager.setQuittingForUpdate = function (value) {
order.push("setQuittingForUpdate");
originalSetQuitting.call(this, value);
};
// queryDirtyEditors reports a clean editor state.
const fakeDirtyEditorGuard = {
queryDirtyEditors() {
order.push("queryDirtyEditors");
return Promise.resolve(false);
},
};
const win = makeBroadcastWindow();
// Capture the watchdog so the test doesn't wait on the real 10s timer.
const originalSetTimeout = global.setTimeout;
global.setTimeout = () => ({ unref() {} });
try {
await withMocks(
{
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
browserWindows: [win],
},
async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// Clean editors → install runs as before:
// dirty check first, then commit-to-quit, then quitAndInstall.
assert.equal(order[0], "queryDirtyEditors");
assert.deepEqual(fakeWindowManager.calls, [true]);
assert.ok(order.indexOf("setQuittingForUpdate") < order.indexOf("quitAndInstall"));
assert.equal(order.includes("quitAndInstall"), true);
assert.equal(fakeGlobalShortcut.cleanupCount, process.platform === "darwin" ? 1 : 0);
// No needs-save broadcast when nothing is dirty.
assert.equal(win.sentChannels.includes("netcatty:update:needs-save"), false);
},
);
} finally {
global.setTimeout = originalSetTimeout;
}
});
test("install handler installs directly when no main window is reachable", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
// Default fake windowManager has no getMainWindow() → getMainWebContents()
// returns null → there's no user to ask, so the install must proceed without
// ever calling queryDirtyEditors (matches the before-quit fail-open path).
let dirtyCheckCalled = false;
const fakeDirtyEditorGuard = {
queryDirtyEditors() {
dirtyCheckCalled = true;
return Promise.resolve(true);
},
};
const originalSetTimeout = global.setTimeout;
global.setTimeout = () => ({ unref() {} });
try {
await withMocks(
{ autoUpdater, dirtyEditorGuard: fakeDirtyEditorGuard },
async ({ bridge, fakeWindowManager }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.equal(dirtyCheckCalled, false);
assert.deepEqual(fakeWindowManager.calls, [true]);
assert.equal(order.includes("quitAndInstall"), true);
},
);
} finally {
global.setTimeout = originalSetTimeout;
}
});