879 lines
28 KiB
JavaScript
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;
|
||
|
|
}
|
||
|
|
});
|