fix: honor automatic updates preference and add Settings controls

This commit is contained in:
amosblomqvist
2026-10-25 19:19:38 +02:00
parent b277ee3e0f
commit 769fc85e17
14 changed files with 820 additions and 75 deletions
+299
View File
@@ -0,0 +1,299 @@
/** Actual shell functions with fake Electron/updater/filesystem. No app,
* profile, network, timer or updater download/install operation is run. */
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createContext, runInContext } from "node:vm";
import { describe, it } from "node:test";
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
const mainSource = readFileSync(mainFile, "utf8");
const mainRequire = createRequire(mainFile);
function fixture({ packaged = true, updaterMissing = false } = {}) {
let settings: string | Error = JSON.stringify({ version: 1, general: { autoUpdate: true } });
const settingsReads: string[] = [];
const order: string[] = [];
const intervals: { callback: () => void; delay: number }[] = [];
const handles = new Map<string, (event: unknown) => unknown>();
const windows: FakeWindow[] = [];
let enumerationFails = false;
let userData = "/fixture/custom-user-data";
let menu: { submenu?: { label?: string; click?: () => void }[] }[] = [];
class FakeUpdater extends EventEmitter {
checks = 0;
autoDownload = false;
autoInstallOnAppQuit = false;
result: object | null = { updateInfo: { version: "0.3.7" } };
failure: Error | null = null;
checkForUpdates() {
this.checks++;
this.emit("checking-for-update");
if (this.failure) return Promise.reject(this.failure);
if (this.result) this.emit("update-not-available", { version: "0.3.7" });
return Promise.resolve(this.result);
}
quitAndInstall() { order.push("install"); }
}
const updater = new FakeUpdater();
class FakeWebContents extends EventEmitter {
mainFrame = { url: "http://127.0.0.1:9000/" };
messages: { channel: string; value: unknown }[] = [];
loading = false;
fails = false;
send(channel: string, value: unknown) {
if (this.fails) throw new Error("window destroyed");
this.messages.push({ channel, value: JSON.parse(JSON.stringify(value)) });
}
isLoadingMainFrame() { return this.loading; }
setWindowOpenHandler() {}
getZoomFactor() { return 1; }
}
class FakeWindow extends EventEmitter {
webContents = new FakeWebContents();
minimized = false;
focused = false;
constructor() { super(); windows.push(this); }
loadURL() { this.webContents.loading = true; }
isMinimized() { return this.minimized; }
restore() { this.minimized = false; }
focus() { this.focused = true; }
show() {}
static getAllWindows() {
if (enumerationFails) throw new Error("window enumeration failed");
return windows;
}
static fromWebContents(contents: FakeWebContents) { return windows.find(win => win.webContents === contents); }
}
const app = Object.assign(new EventEmitter(), {
isPackaged: packaged,
name: "LearnHarness",
setName() {},
getPath: () => userData,
setPath: (_: string, value: string) => { userData = value; },
requestSingleInstanceLock: () => false,
quit() {},
whenReady: async () => {},
});
const electron = {
app, BrowserWindow: FakeWindow,
Menu: { buildFromTemplate: (value: typeof menu) => value, setApplicationMenu: (value: typeof menu) => { menu = value; } },
ipcMain: { on() {}, handle: (channel: string, fn: (event: unknown) => unknown) => handles.set(channel, fn) },
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
};
const context = createContext({
require: (id: string) => {
if (id === "electron") return electron;
if (id === "electron-updater") {
if (updaterMissing) throw new Error("missing updater");
return { autoUpdater: updater };
}
if (id === "node:fs") return {
readFileSync: (file: string) => {
settingsReads.push(file);
if (settings instanceof Error) throw settings;
return settings;
}, mkdirSync() {}, appendFileSync() {},
};
return mainRequire(id);
},
process: { argv: [], env: { LH_DEV_URL: "http://127.0.0.1:9000" }, platform: "darwin", on() {} },
__dirname: path.dirname(mainFile), URL,
setInterval: (callback: () => void, delay: number) => intervals.push({ callback, delay }),
setTimeout() {},
fixtureChild: { kill: (signal: string) => order.push(signal) },
});
runInContext(mainSource, context, { filename: mainFile });
const run = (code: string) => runInContext(code, context);
return {
run, updater, windows, intervals, handles, settingsReads, order, app,
setSettings: (value: unknown) => { settings = JSON.stringify(value); },
setSettingsFailure: (value: string | Error) => { settings = value; },
setEnumerationFailure: (value: boolean) => { enumerationFails = value; },
window: () => new FakeWindow(),
state: () => JSON.parse(JSON.stringify(run("updateState"))),
check: async (manual = false) => { run(`checkForUpdates({ manual: ${manual} })`); await Promise.resolve(); await Promise.resolve(); },
menuCheck: () => menu[0]!.submenu!.find(item => item.label === "Check for Updates…")!.click!(),
};
}
describe("automatic-update preference gate", () => {
it("explicit OFF skips automatic checks, but manual checks still run without reading the preference", async () => {
const f = fixture();
f.setSettings({ general: { autoUpdate: false } });
f.run("setupAutoUpdater()");
await f.check();
assert.equal(f.updater.checks, 0);
assert.deepEqual(f.settingsReads, ["/fixture/custom-user-data-dev/data/settings.json"]);
await f.check(true);
assert.equal(f.updater.checks, 1);
assert.equal(f.settingsReads.length, 1);
assert.equal(f.updater.autoDownload, true);
assert.equal(f.updater.autoInstallOnAppQuit, true);
});
for (const [label, setting] of [
["ON", { general: { autoUpdate: true } }], ["absent field", { general: {} }],
["absent section", { version: 1 }], ["null", null], ["non-boolean", { general: { autoUpdate: "false" } }],
] as const) {
it(`${label} keeps automatic checks enabled`, async () => {
const f = fixture(); f.setSettings(setting); f.run("setupAutoUpdater()"); await f.check();
assert.equal(f.updater.checks, 1);
});
}
for (const [label, failure] of [["missing", new Error("ENOENT")], ["unreadable", new Error("EACCES")], ["corrupt", "{nope"]] as const) {
it(`${label} settings keep automatic checks enabled`, async () => {
const f = fixture(); f.setSettingsFailure(failure); f.run("setupAutoUpdater()"); await f.check();
assert.equal(f.updater.checks, 1);
});
}
it("re-reads the setting on each check, without triggering a check merely when saved", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.setSettings({ general: { autoUpdate: false } }); await f.check();
f.setSettings({ general: { autoUpdate: true } });
assert.equal(f.updater.checks, 0);
await f.check();
f.setSettings({ general: { autoUpdate: false } }); await f.check();
assert.equal(f.updater.checks, 1);
assert.equal(f.settingsReads.length, 3);
});
for (const label of ["unpackaged", "missing updater"] as const) {
it(`${label} stays unavailable, not a saved OFF preference`, async () => {
const f = fixture({ packaged: label !== "unpackaged", updaterMissing: label === "missing updater" });
f.run("setupAutoUpdater()"); await f.check(true); await f.check();
assert.equal(f.updater.checks, 0);
assert.equal(f.settingsReads.length, 0);
assert.equal(f.state().status, "disabled");
});
}
});
describe("status observation never controls the updater", () => {
it("reports events, but a staged update stays visible without suppressing future checks", async () => {
const f = fixture(); const win = f.window(); f.run("setupAutoUpdater()");
f.updater.emit("checking-for-update"); assert.equal(f.state().status, "checking");
f.updater.emit("update-available", { version: "0.3.8" });
assert.equal(f.state().status, "downloading"); assert.equal(f.state().version, "0.3.8");
f.updater.emit("update-downloaded", { version: "0.3.8" });
const ready = f.state();
await f.check();
f.updater.emit("error", new Error("failure with sensitive diagnostic details"));
assert.equal(f.updater.checks, 1);
assert.deepEqual(f.state(), ready);
f.updater.emit("update-downloaded", { version: "0.3.9" });
assert.equal(f.state().version, "0.3.9");
assert.ok(f.state().revision > ready.revision);
assert.equal(win.webContents.messages.at(-1)!.channel, "lh:update-status");
});
for (const status of ["checking", "downloading", "error", "disabled", "downloaded"]) {
it(`a ${status} display state does not block a future automatic check`, async () => {
const f = fixture(); f.run(`setupAutoUpdater(); reportUpdateStatus("${status}")`); await f.check();
assert.equal(f.updater.checks, 1);
});
}
it("check failures settle to a generic error and later checks still run", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.updater.failure = new Error("private endpoint/diagnostic detail"); await f.check();
assert.equal(f.state().status, "error");
assert.ok(!JSON.stringify(f.state()).includes("private"));
f.updater.failure = null; await f.check();
assert.equal(f.updater.checks, 2); assert.equal(f.state().status, "up-to-date");
});
it("a declined check makes no up-to-date claim", async () => {
const f = fixture(); f.updater.result = null; f.run("setupAutoUpdater()"); await f.check(true);
assert.equal(f.state().status, "idle");
});
it("window enumeration/send failures do not break checks or the next window's notification", async () => {
const f = fixture(); const bad = f.window(); bad.webContents.fails = true; const good = f.window();
f.run("setupAutoUpdater()"); await f.check();
assert.equal(good.webContents.messages.at(-1)!.channel, "lh:update-status");
f.setEnumerationFailure(true); await f.check();
f.setEnumerationFailure(false); await f.check();
assert.equal(f.updater.checks, 3);
});
it("OFF does not cancel in-flight events or alter staging/install-on-quit; Restart kills server first", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.updater.emit("update-available", { version: "0.3.8" });
f.setSettings({ general: { autoUpdate: false } });
f.updater.emit("update-downloaded", { version: "0.3.8" }); await f.check();
assert.equal(f.state().status, "downloaded"); assert.equal(f.updater.checks, 0);
assert.equal(f.updater.autoDownload, true); assert.equal(f.updater.autoInstallOnAppQuit, true);
f.run("serverProcess = fixtureChild; installUpdateNow()");
assert.deepEqual(f.order, ["SIGTERM", "install"]);
});
});
describe("startup, timer, trusted IPC and menu wiring", () => {
it("starts and retains the 24h timer without any Settings subscription, checking current preference each time", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } });
await f.run("main()");
assert.equal(f.updater.checks, 0);
assert.equal(f.intervals.length, 1); assert.equal(f.intervals[0]!.delay, 24 * 60 * 60 * 1000);
f.setSettings({ general: { autoUpdate: true } }); f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 1);
f.windows.length = 0; f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 2, "checks also work with every window closed");
});
it("ON/default boot checks immediately and status never disables the scheduled check", async () => {
const f = fixture(); f.setSettings({ version: 1 }); await f.run("main()");
assert.equal(f.updater.checks, 1);
f.run('reportUpdateStatus("downloading", "0.3.8")'); f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 2);
});
it("snapshot/manual IPC accepts only an owned main frame and snapshot reads never check", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
const sender = f.windows[0]!.webContents;
const event = { sender, senderFrame: sender.mainFrame };
const get = f.handles.get("lh:update:get")!; const check = f.handles.get("lh:update:check")!;
assert.equal((get(event) as { status: string }).status, "idle"); assert.equal(f.updater.checks, 0);
check(event); assert.equal(f.updater.checks, 1);
for (const bad of [
{ sender: {}, senderFrame: sender.mainFrame },
{ sender, senderFrame: { url: sender.mainFrame.url } },
]) {
assert.throws(() => get(bad), /Untrusted/); assert.throws(() => check(bad), /Untrusted/);
}
sender.mainFrame.url = "https://untrusted.invalid/";
assert.throws(() => check(event), /Untrusted/);
assert.equal(f.updater.checks, 1);
});
it("menu restores/focuses a minimized window, opens General, and manually checks while OFF", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
const win = f.windows[0]!; win.webContents.loading = false; win.minimized = true;
f.menuCheck();
assert.equal(win.minimized, false); assert.equal(win.focused, true);
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings" && message.value === "general"));
assert.equal(f.updater.checks, 1);
});
it("menu reopens a window and delivers General after load; loading existing windows are covered too", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
f.windows.length = 0; f.menuCheck();
assert.equal(f.windows.length, 1); assert.equal(f.updater.checks, 1);
const win = f.windows[0]!;
assert.ok(!win.webContents.messages.some(message => message.channel === "lh:open-settings"));
win.webContents.emit("did-finish-load");
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings"));
f.menuCheck(); win.webContents.emit("did-finish-load");
assert.equal(win.webContents.messages.filter(message => message.channel === "lh:open-settings").length, 2);
});
it("menu navigation failure does not prevent its manual check", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
f.windows[0]!.webContents.fails = true; f.windows[0]!.webContents.loading = false;
f.menuCheck(); assert.equal(f.updater.checks, 1);
});
});
+36 -2
View File
@@ -72,18 +72,52 @@ describe("desktop window chrome", () => {
bridge = api;
},
},
ipcRenderer: {},
ipcRenderer: { on() {} },
}),
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32");
assert.deepEqual(Object.keys(bridge).sort(), [
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "subscriptions",
"chatgpt", "checkForUpdates", "expandWindow", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
]);
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
});
});
describe("update preload bridge", () => {
it("buffers the menu request, unsubscribes safely, and uses the matching snapshot/check channels", async () => {
let bridge!: {
onOpenSettings: (cb: (section: string) => void) => () => void;
getUpdateState: () => Promise<unknown>;
checkForUpdates: () => Promise<void>;
};
const handlers = new Map<string, (event: unknown, value: string) => void>();
const invoked: string[] = [];
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
process: { platform: "darwin" },
require: () => ({
contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } },
ipcRenderer: {
on: (channel: string, listener: (event: unknown, value: string) => void) => handlers.set(channel, listener),
invoke: async (channel: string) => { invoked.push(channel); },
},
}),
});
const send = () => handlers.get("lh:open-settings")!({}, "general");
const seen: string[] = [];
send(); const off = bridge.onOpenSettings(section => seen.push(section));
assert.deepEqual(seen, ["general"], "pre-subscription request is delivered once");
send(); assert.deepEqual(seen, ["general", "general"]);
off(); send(); assert.equal(seen.length, 2);
const next: string[] = []; const offNext = bridge.onOpenSettings(section => next.push(section));
assert.deepEqual(next, ["general"]);
off(); send(); assert.equal(next.length, 2, "old cleanup cannot remove a newer subscriber");
offNext();
await bridge.getUpdateState(); await bridge.checkForUpdates();
assert.deepEqual(invoked, ["lh:update:get", "lh:update:check"]);
});
});
describe("desktop packaging policy", () => {
const yaml = require("js-yaml") as { load(source: string): Record<string, any> };
const config = yaml.load(readFileSync(new URL("../electron-builder.yml", import.meta.url), "utf8"));
+8 -7
View File
@@ -1,7 +1,7 @@
/**
* Update-banner render tests (Wave C, 2026-10-10): the banner is a pure
* function of the last lh:update-status push — hidden without one (the
* browser/dev world: no window.lh at all), visible with the downloaded
* function of the shell's observational status — hidden without a bridge
* or snapshot/event, visible with the downloaded
* status, Restart wired to the bridge. The subscribe effect only runs
* client-side, so the SSR pass pins the hidden state; the pushed state is
* exercised by mounting with the bridge stubbed (classic-JSX shim as in
@@ -17,6 +17,7 @@ import * as ReactDOMClient from "react-dom/client";
import { flushSync } from "react-dom";
import { parseHTML } from "linkedom";
import UpdateBanner from "../components/update-banner.tsx";
import type { UpdateState } from "../components/settings/update-settings.tsx";
// linkedom as the DOM for the interactive pass (first createRoot test in
// the repo — the banner's push→render→click flow needs real effects).
@@ -36,9 +37,9 @@ describe("update banner (Wave C)", () => {
});
it("renders nothing when the bridge exists but no status has arrived", () => {
const listeners: Array<(s: { status: string; version: string | null }) => void> = [];
const listeners: Array<(s: UpdateState) => void> = [];
(window as { lh?: unknown }).lh = {
onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => {
onUpdateStatus: (cb: (s: UpdateState) => void) => {
listeners.push(cb);
return () => {};
},
@@ -50,10 +51,10 @@ describe("update banner (Wave C)", () => {
});
it("shows the version + Restart once the downloaded push arrives, and Restart installs", async () => {
let pushed: ((s: { status: string; version: string | null }) => void) | null = null;
let pushed: ((s: UpdateState) => void) | null = null;
let installed = 0;
(window as { lh?: unknown }).lh = {
onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => {
onUpdateStatus: (cb: (s: UpdateState) => void) => {
pushed = cb;
return () => {};
},
@@ -68,7 +69,7 @@ describe("update banner (Wave C)", () => {
assert.equal(host.innerHTML, "", "hidden before the push");
await new Promise((r) => setTimeout(r, 0));
flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1" }));
flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1", revision: 1 }));
assert.ok(host.innerHTML.includes("0.3.1"), "the version renders");
assert.ok(host.innerHTML.includes("Restart"), "the restart affordance renders");
+223
View File
@@ -0,0 +1,223 @@
/** Update UI with a fake preload bridge: no Electron, network or updates. */
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
import assert from "node:assert/strict";
import { describe, it, afterEach } from "node:test";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { UpdateSettings, updateStatusText, MANUAL_UPDATE_HINT, AUTO_UPDATE_HINT, type UpdateState } from "../components/settings/update-settings.tsx";
import UpdateBanner from "../components/update-banner.tsx";
import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx";
import nextConfig from "../next.config.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent });
const originalFetch = globalThis.fetch;
const roots: { root: Root; host: HTMLElement }[] = [];
const state = (over: Partial<UpdateState> = {}): UpdateState => ({ status: "idle", version: null, revision: 0, ...over });
const props = { autoUpdate: true, onAutoUpdateChange: () => {}, version: "0.3.7", homepage: "https://github.com/amosblomqvist/learnharness" };
const q = (host: ParentNode, id: string) => host.querySelector<HTMLElement>(`[data-testid='${id}']`);
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function bridge(initial = state(), snapshot = Promise.resolve(initial)) {
const listeners = new Set<(value: UpdateState) => void>();
const calls = { check: 0, install: 0, get: 0 };
let onOpen: ((section: string) => void) | null = null;
let checkFailure: Error | null = null;
window.lh = {
expandWindow() {},
getUpdateState: () => { calls.get++; return snapshot; },
onUpdateStatus: cb => { listeners.add(cb); return () => { listeners.delete(cb); }; },
checkForUpdates: async () => { calls.check++; if (checkFailure) throw checkFailure; },
installUpdate: () => { calls.install++; },
onOpenSettings: cb => { onOpen = cb; return () => { onOpen = null; }; },
};
return {
calls, listeners,
failCheck: () => { checkFailure = new Error("sensitive raw diagnostics"); },
push: async (value: UpdateState) => { await act(async () => { for (const listener of listeners) listener(value); }); },
open: async () => { await act(async () => onOpen?.("general")); },
};
}
async function mount(element: React.ReactElement) {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host); roots.push({ root, host });
await act(async () => { root.render(element); });
return host;
}
async function click(element: HTMLElement | null) {
assert.ok(element, "missing click target");
await act(async () => { element.click(); });
}
afterEach(async () => {
for (const { root, host } of roots.splice(0)) {
await act(async () => { root.unmount(); }); host.remove();
}
delete window.lh;
globalThis.fetch = originalFetch;
});
describe("version and contextual copy", () => {
it("build metadata comes from package.json and exposes only public version/homepage values", () => {
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
assert.deepEqual(nextConfig.env, { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage });
});
it("never claims live/latest information and keeps updater errors generic", () => {
assert.equal(updateStatusText(null), null);
assert.equal(updateStatusText(state()), null);
assert.equal(updateStatusText(state({ status: "disabled" })), null);
assert.equal(updateStatusText(state({ status: "checking" })), "Checking for updates…");
assert.equal(updateStatusText(state({ status: "downloading", version: "0.3.8" })), "Downloading version 0.3.8…");
assert.equal(updateStatusText(state({ status: "up-to-date" })), "No newer version was found at the last check.");
assert.equal(updateStatusText(state({ status: "downloaded", version: "0.3.8" })), "Version 0.3.8 is ready. It will install when you quit the app.");
assert.equal(updateStatusText(state({ status: "error" })), "Couldn’t check for updates. Try again later.");
assert.equal(updateStatusText(state({ status: "downloading" })), "Downloading an update…");
assert.equal(updateStatusText(state({ status: "downloaded" })), "An update is ready. It will install when you quit the app.");
});
});
describe("General update rows", () => {
it("browser/SSR shows version and installed-version release notes, without inactive controls or claims", () => {
const html = renderToStaticMarkup(<UpdateSettings {...props} />);
assert.ok(html.includes("Version 0.3.7"));
assert.ok(html.includes(`${props.homepage}/releases/tag/v0.3.7`));
assert.ok(!html.includes("update-check")); assert.ok(!html.includes("row-auto-update"));
assert.ok(!html.includes(MANUAL_UPDATE_HINT)); assert.ok(!html.includes("update-status"));
});
it("unpackaged/unavailable updater shows the version-only surface", async () => {
const b = bridge(state({ status: "disabled" })); const host = await mount(<UpdateSettings {...props} />);
assert.ok(q(host, "row-version")); assert.equal(q(host, "update-check"), null);
assert.equal(q(host, "row-auto-update"), null); assert.equal(b.calls.check, 0);
});
it("idle shows two rows and clear consequences; opening Settings never checks", async () => {
const b = bridge(); const seen: boolean[] = [];
const host = await mount(<UpdateSettings {...props} onAutoUpdateChange={value => seen.push(value)} />);
assert.deepEqual([...host.children].map(el => el.getAttribute("data-testid")), ["row-version", "row-auto-update"]);
assert.ok(q(host, "row-version")!.contains(q(host, "update-check")));
assert.ok(host.textContent!.includes(MANUAL_UPDATE_HINT)); assert.ok(host.textContent!.includes(AUTO_UPDATE_HINT));
assert.equal(q(host, "update-status"), null); assert.equal(b.calls.check, 0);
await click(q(host, "toggle-auto-update")); assert.deepEqual(seen, [false]);
await click(q(host, "update-check")); assert.equal(b.calls.check, 1);
});
it("manual check remains available while automatic checks are OFF", async () => {
const b = bridge(); const host = await mount(<UpdateSettings {...props} autoUpdate={false} />);
assert.equal(q(host, "toggle-auto-update")!.getAttribute("aria-checked"), "false");
await click(q(host, "update-check")); assert.equal(b.calls.check, 1);
});
it("checking/downloading shows context and disables only the UI button; ready offers Restart", async () => {
const b = bridge(); const host = await mount(<UpdateSettings {...props} />);
await b.push(state({ status: "checking", revision: 1 }));
assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, true);
assert.equal(q(host, "update-check")!.getAttribute("aria-busy"), "true");
assert.equal(q(host, "update-status")!.textContent, "Checking for updates…");
await b.push(state({ status: "downloading", version: "0.3.8", revision: 2 }));
assert.equal(q(host, "update-status")!.textContent, "Downloading version 0.3.8…");
assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, true);
await b.push(state({ status: "downloaded", version: "0.3.8", revision: 3 }));
assert.equal(q(host, "update-check"), null); assert.ok(q(host, "update-status")!.textContent!.includes("quit the app"));
await click(q(host, "update-restart")); assert.equal(b.calls.install, 1);
});
it("up-to-date and failure statuses allow another manual check", async () => {
const b = bridge(); const host = await mount(<UpdateSettings {...props} />);
await b.push(state({ status: "up-to-date", revision: 1 }));
assert.equal(q(host, "update-status")!.textContent, "No newer version was found at the last check.");
await b.push(state({ status: "error", revision: 2 }));
assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, false);
assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later.");
await click(q(host, "update-check")); assert.equal(b.calls.check, 1);
});
it("bridge command rejection is handled without exposing raw diagnostics", async () => {
const b = bridge(); b.failCheck(); const host = await mount(<UpdateSettings {...props} />);
await click(q(host, "update-check"));
assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later.");
assert.ok(!host.textContent!.includes("sensitive"));
});
it("late Settings and banner subscribers recover the staged update without an event", async () => {
const b = bridge(state({ status: "downloaded", version: "0.3.8", revision: 5 }));
const host = await mount(<><UpdateSettings {...props} /><UpdateBanner /></>);
assert.ok(q(host, "update-restart")); assert.ok(q(host, "update-banner"));
assert.ok(q(host, "update-banner")!.textContent!.includes("0.3.8"));
await click(q(host, "update-banner")!.querySelector("button")); assert.equal(b.calls.install, 1);
assert.equal(b.calls.check, 0);
});
it("a delayed older snapshot cannot overwrite a newer push in Settings or the banner", async () => {
const snapshot = deferred<UpdateState>(); const b = bridge(state(), snapshot.promise);
const host = await mount(<><UpdateSettings {...props} /><UpdateBanner /></>);
await b.push(state({ status: "downloaded", version: "0.3.8", revision: 4 }));
await act(async () => { snapshot.resolve(state({ status: "checking", revision: 3 })); });
assert.ok(q(host, "update-restart")); assert.ok(q(host, "update-banner"));
});
it("snapshot rejection cannot erase an already received event and late callbacks are unsubscribed", async () => {
const snapshot = deferred<UpdateState>(); const b = bridge(state(), snapshot.promise);
const host = await mount(<UpdateSettings {...props} />);
await b.push(state({ status: "downloaded", version: "0.3.8", revision: 1 }));
await act(async () => { snapshot.reject(new Error("IPC disconnected")); });
assert.ok(q(host, "update-restart"));
const { root } = roots.pop()!; await act(async () => { root.unmount(); }); host.remove();
assert.equal(b.listeners.size, 0);
});
it("snapshot rejection without an event is handled with a generic message", async () => {
const snapshot = deferred<UpdateState>(); bridge(state(), snapshot.promise);
const host = await mount(<UpdateSettings {...props} />);
await act(async () => { snapshot.reject(new Error("sensitive error")); });
assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later.");
assert.ok(!host.textContent!.includes("sensitive"));
});
});
describe("Settings integration", () => {
it("the toggle sends the complete General section and follows stored server truth", async () => {
bridge();
// A synthetic future sibling exercises the caller's section-preserving
// contract without introducing a new product setting or changing its API.
const record = { version: 1, general: { autoUpdate: true, futureSibling: "keep" } };
const puts: unknown[] = [];
globalThis.fetch = async (input, init) => {
assert.equal(String(input), init?.method === "PUT" ? "/api/settings/general" : "/api/settings");
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body)); puts.push(body);
return Response.json({ settings: { ...record, general: body } });
}
return Response.json({ settings: record });
};
const host = await mount(<SettingsDialogBody initialSection="general" onClose={() => {}} />);
await click(q(host, "toggle-auto-update"));
assert.deepEqual(puts, [{ autoUpdate: false, futureSibling: "keep" }]);
assert.equal(q(host, "toggle-auto-update")!.getAttribute("aria-checked"), "false");
});
it("the shell's navigation request opens General, but opening alone never checks", async () => {
const b = bridge();
globalThis.fetch = async () => Response.json({ settings: { version: 1 } });
const host = await mount(<SettingsDialogProvider><span>app</span></SettingsDialogProvider>);
assert.equal(q(document, "settings-dialog"), null);
await b.open();
assert.equal(q(document, "settings-nav-general")!.getAttribute("aria-current"), "true");
assert.ok(q(document, "row-version")); assert.equal(b.calls.check, 0);
assert.ok(host.textContent!.includes("app"));
});
});
+2 -2
View File
@@ -17,7 +17,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Hint, hintText } from "../components/ui/hint.tsx";
import { TOOLTIP_DELAY_MS } from "../components/ui/tooltip.tsx";
import { AUTO_UPDATE_HINT } from "../components/settings/settings-dialog.tsx";
import { AUTO_UPDATE_HINT } from "../components/settings/update-settings.tsx";
import { IS_MAC } from "../src/core/keyboard.ts";
describe("hintText — the parenthesized composition", () => {
@@ -46,7 +46,7 @@ describe("subtlety round pins (owner 2026-09-25)", () => {
describe("spot-pins: the dev-speak cull (owner copy review at merge)", () => {
it("the auto-update hint is honest user language — no stream jargon", () => {
assert.equal(AUTO_UPDATE_HINT, "Automatic updates arrive in an upcoming release");
assert.equal(AUTO_UPDATE_HINT, "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.");
assert.ok(!AUTO_UPDATE_HINT.includes("wave"), "no stream vocabulary");
});
});