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
+1 -1
View File
@@ -63,7 +63,7 @@ export function SettingsRow({
dimmed,
}: {
label: string;
hint?: string;
hint?: ReactNode;
children: React.ReactNode;
testid?: string;
/** Dependency graying, graph-view style: the whole row dims when the
+9 -18
View File
@@ -40,7 +40,8 @@ import {
type UserSettings,
} from "@/src/core";
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { SettingsSection } from "./controls.tsx";
import { UpdateSettings } from "./update-settings.tsx";
import { AgentSection } from "./agent-section.tsx";
import type { ModelsSettings } from "@/src/core";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell";
@@ -75,10 +76,6 @@ const DialogContext = createContext<{ open: (section?: string, row?: string) =>
/** AppShell-level mount: the sidebar row and the mod+, chord call this.
* `open(section)` lands the dialog on a specific section (the first-run
* setup flow deep-links to Keys when no provider key exists yet). */
/** The auto-update row's hint (tooltip wave): honest user language — no
* stream vocabulary. Exported for the copy pin (tooltip-mechanism test). */
export const AUTO_UPDATE_HINT = "Automatic updates arrive in an upcoming release";
export function SettingsDialogProvider({
children,
}: {
@@ -92,6 +89,7 @@ export function SettingsDialogProvider({
setLandOnRow(row ?? null);
setShow(true);
}, []);
useEffect(() => window.lh?.onOpenSettings?.((section) => open(section)), [open]);
return (
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
{children}
@@ -362,19 +360,12 @@ export function SettingsDialogBody({
title="General"
testid="section-general"
>
<SettingsRow
label="Auto-update"
hint={AUTO_UPDATE_HINT}
testid="row-auto-update"
>
<Toggle
checked={record.general?.autoUpdate ?? true}
label="auto update"
onChange={(on) =>
void write("general", { autoUpdate: on })
}
/>
</SettingsRow>
<UpdateSettings
autoUpdate={record.general?.autoUpdate ?? true}
onAutoUpdateChange={(on) =>
void write("general", { ...record.general, autoUpdate: on })
}
/>
</SettingsSection>
)}
{active === "keys" && (
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { BrailleSpinner } from "@/components/braille-spinner";
import { SettingsRow, Toggle } from "./controls.tsx";
/** Observational snapshot from electron/main.cjs; never updater permission. */
export type UpdateState = {
status: "idle" | "checking" | "downloading" | "up-to-date" | "downloaded" | "error" | "disabled";
version: string | null;
revision: number;
};
export const APP_VERSION = process.env.LH_VERSION ?? "";
const HOMEPAGE = process.env.LH_HOMEPAGE ?? "";
export const MANUAL_UPDATE_HINT = "Checking downloads available updates. Downloaded updates install when you quit the app.";
export const AUTO_UPDATE_HINT = "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.";
const UPDATE_ERROR = "Couldn’t check for updates. Try again later.";
/** Subscribe before requesting the snapshot. IPC replies and pushes can
* cross; a revision prevents an older reply from hiding a newer event.
* The banner shares this read-only observer so late loads recover too. */
export function useUpdateState() {
const [state, setState] = useState<UpdateState | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const lh = window.lh;
if (!lh) return;
let live = true;
let revision = -1;
const accept = (next: UpdateState) => {
if (!live || next.revision < revision) return;
revision = next.revision;
setState(next);
setFailed(false);
};
const off = lh.onUpdateStatus?.(accept);
void lh.getUpdateState?.().then(accept).catch(() => {
if (live && revision === -1) setFailed(true);
});
return () => { live = false; off?.(); };
}, []);
return { state, failed };
}
export function updateStatusText(state: UpdateState | null): string | null {
switch (state?.status) {
case "checking": return "Checking for updates…";
case "downloading": return state.version ? `Downloading version ${state.version}…` : "Downloading an update…";
case "up-to-date": return "No newer version was found at the last check.";
case "downloaded": return `${state.version ? `Version ${state.version}` : "An update"} is ready. It will install when you quit the app.`;
case "error": return UPDATE_ERROR;
default: return null;
}
}
/** Version information is useful everywhere; update controls only belong
* to a shell with an available updater. Opening this row never checks. */
export function UpdateSettings({
autoUpdate,
onAutoUpdateChange,
version = APP_VERSION,
homepage = HOMEPAGE,
}: {
autoUpdate: boolean;
onAutoUpdateChange: (on: boolean) => void;
version?: string;
homepage?: string;
}) {
const { state, failed } = useUpdateState();
const [checkFailed, setCheckFailed] = useState(false);
useEffect(() => setCheckFailed(false), [state]);
const active = state !== null && state.status !== "disabled";
const busy = state?.status === "checking" || state?.status === "downloading";
const status = state?.status === "downloaded" ? updateStatusText(state)
: failed || checkFailed ? UPDATE_ERROR : updateStatusText(state);
const releaseNotes = version && homepage ? `${homepage}/releases/tag/v${version}` : null;
const check = () => {
setCheckFailed(false);
void window.lh?.checkForUpdates?.().catch(() => setCheckFailed(true));
};
return (
<>
<SettingsRow
label={`Version ${version || "unknown"}`}
testid="row-version"
hint={
<>
{status && <span className="block" role="status" data-testid="update-status">{status}</span>}
{active && <span className="block">{MANUAL_UPDATE_HINT}</span>}
{releaseNotes && <a href={releaseNotes} target="_blank" rel="noopener noreferrer" className="underline-offset-2 hover:text-foreground hover:underline" data-testid="release-notes">Release notes</a>}
</>
}
>
{active && (state.status === "downloaded" ? (
<Button type="button" size="xs" className="cursor-pointer" data-testid="update-restart" onClick={() => window.lh?.installUpdate?.()}>Restart</Button>
) : (
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" data-testid="update-check" disabled={busy} aria-busy={busy} onClick={check}>
{busy && <BrailleSpinner label={state.status === "downloading" ? "Downloading update" : "Checking for updates"} />}
Check for updates
</Button>
))}
</SettingsRow>
{active && (
<SettingsRow label="Automatic updates" hint={AUTO_UPDATE_HINT} testid="row-auto-update">
<Toggle checked={autoUpdate} label="auto update" onChange={onAutoUpdateChange} />
</SettingsRow>
)}
</>
);
}
+7 -2
View File
@@ -49,6 +49,7 @@ const STORAGE_KEY = "lh:sidebar-open";
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
*/
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
import type { UpdateState } from "@/components/settings/update-settings.tsx";
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
@@ -66,9 +67,13 @@ declare global {
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
getZoom?: () => Promise<number>;
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
/** Observational update events and a snapshot for late subscribers. */
onUpdateStatus?: (cb: (status: UpdateState) => void) => () => void;
getUpdateState?: () => Promise<UpdateState>;
/** Explicit manual check also downloads and stages available updates. */
checkForUpdates?: () => Promise<void>;
installUpdate?: () => void;
onOpenSettings?: (cb: (section: string) => void) => () => void;
};
}
}
+5 -18
View File
@@ -3,32 +3,19 @@
/**
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
* restart-banner UX). The shell pushes "lh:update-status" once a staged
* update exists (electron/main.cjs update-downloaded); this is the whole
* renderer surface — a small fixed card, bottom-right, offering Restart.
* update exists (electron/main.cjs update-downloaded). Snapshot retrieval
* also covers a late load. The card offers Restart alongside Settings.
* Browser builds have no window.lh and never see a push: renders null.
*
* No dismiss: a downloaded update installs on quit anyway
* (autoInstallOnAppQuit); the banner is a courtesy, not a gate.
*/
import { useEffect, useState } from "react";
// The window.lh bridge type lives in components/sidebar.tsx (the incumbent
// declare-global) — onUpdateStatus/installUpdate are declared there too.
type UpdateStatus = { status: string; version: string | null };
import { useUpdateState } from "@/components/settings/update-settings.tsx";
export default function UpdateBanner() {
const [ready, setReady] = useState<UpdateStatus | null>(null);
useEffect(() => {
// Absent bridge (browser / dev) → stays hidden forever.
return window.lh?.onUpdateStatus?.((s) => {
if (s?.status === "downloaded") setReady(s);
});
}, []);
if (!ready) return null;
const { state: ready } = useUpdateState();
if (ready?.status !== "downloaded") return null;
return (
<div
data-testid="update-banner"
+84 -21
View File
@@ -24,7 +24,7 @@
* would shadow every settings-page key change until restart.
*/
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
@@ -140,6 +140,7 @@ let subscriptions = null;
let subscriptionChild = null;
// Per-launch request capability, never OAuth tokens and never renderer state.
let desktopAPISecret = null;
let reopenWindow = null;
const trustedAppWindows = new Set();
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
@@ -436,31 +437,57 @@ const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
* and before-quit alone would race the swap. */
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
let autoUpdater = null; // electron-updater's, packaged builds only
// Observational only: this snapshot NEVER gates a check, download or install.
// The revision lets late subscribers reject an older in-flight IPC snapshot.
let updateState = { status: "idle", version: null, revision: 0 };
function broadcastUpdateStatus(status) {
for (const win of BrowserWindow.getAllWindows()) {
try {
win.webContents.send("lh:update-status", status);
} catch {
/* window mid-teardown — the next push retries */
function reportUpdateStatus(status, version = null) {
try {
// A later check/error must not hide an update already staged for quit.
if (updateState.status === "downloaded" && status !== "downloaded") return;
updateState = { status, version: typeof version === "string" ? version : null, revision: updateState.revision + 1 };
for (const win of BrowserWindow.getAllWindows()) {
try {
win.webContents.send("lh:update-status", updateState);
} catch {
/* window mid-teardown — snapshot retrieval covers a later load */
}
}
} catch {
/* Status reporting must never interrupt the updater. */
}
}
/** Read the SAME data directory passed to the packaged server. Only an
* explicit false opts out; absent/corrupt/unreadable settings keep updates ON.
* Re-read for each automatic check so saving the toggle needs no restart. */
function automaticUpdatesEnabled() {
try {
const settings = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8"));
return settings?.general?.autoUpdate !== false;
} catch {
return true;
}
}
function checkForUpdates({ manual = false } = {}) {
if (!autoUpdater) return;
if (!manual && !automaticUpdatesEnabled()) {
log("auto-update: automatic check skipped (turned off in Settings)");
return;
}
// Do not gate on display state: a stale spinner/error must never disable
// future background checks. electron-updater owns its in-flight work.
autoUpdater
.checkForUpdates()
.then((result) => {
// The download itself is silent (autoDownload); the banner fires on
// update-downloaded. Manual checks additionally answer directly.
if (!manual) return;
const version = result?.updateInfo?.version;
if (!version) dialog.showMessageBox({ type: "info", message: "You're up to date.", detail: `LearnHarness ${app.getVersion()} is the latest version.` });
// A declined check (e.g. an unsupported package) establishes no
// latest-version claim. Real results arrive through updater events.
if (!result) reportUpdateStatus("idle");
})
.catch((err) => {
log(`update check failed: ${err?.message ?? err}`);
if (manual) dialog.showMessageBox({ type: "warning", message: "Couldn't check for updates.", detail: String(err?.message ?? err) });
reportUpdateStatus("error");
});
}
@@ -477,12 +504,14 @@ function installUpdateNow() {
function setupAutoUpdater() {
if (!app.isPackaged) {
log("auto-update: dev build — updater disabled");
reportUpdateStatus("disabled");
return;
}
try {
autoUpdater = require("electron-updater").autoUpdater;
} catch (err) {
log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`);
reportUpdateStatus("disabled");
return;
}
const feed = process.env.LH_UPDATE_FEED;
@@ -500,11 +529,37 @@ function setupAutoUpdater() {
debug: (m) => {}, // too chatty for the persistent log
error: (m) => log(`updater error: ${m}`),
};
autoUpdater.on("checking-for-update", () => reportUpdateStatus("checking"));
autoUpdater.on("update-available", (info) => reportUpdateStatus("downloading", info?.version));
autoUpdater.on("update-not-available", () => reportUpdateStatus("up-to-date"));
autoUpdater.on("update-downloaded", (info) => {
log(`update downloaded: ${info?.version} — banner shown`);
broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null });
reportUpdateStatus("downloaded", info?.version);
});
autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`));
autoUpdater.on("error", (err) => {
log(`updater error: ${err?.message ?? err}`);
reportUpdateStatus("error");
});
}
/** One result surface for manual checks. Preload buffers the navigation
* request until React subscribes, including when all macOS windows closed. */
function checkForUpdatesFromMenu() {
try {
const win = BrowserWindow.getAllWindows()[0] ?? reopenWindow?.();
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
const openSettings = () => {
try { win.webContents.send("lh:open-settings", "general"); } catch { /* window closed */ }
};
if (win.webContents.isLoadingMainFrame()) win.webContents.once("did-finish-load", openSettings);
else openSettings();
}
} catch {
/* Navigation failure must not prevent the manual check. */
}
checkForUpdates({ manual: true });
}
function zoomStep(win, dir) {
@@ -530,9 +585,8 @@ function installMenu() {
label: app.name,
submenu: [
{ role: "about" },
// Manual check (Wave C): answers via native dialog — no renderer
// round-trip. The 24h automatic checks stay silent.
{ label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) },
// Manual checks report their result in Settings → General.
{ label: "Check for Updates…", click: () => checkForUpdatesFromMenu() },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
@@ -654,6 +708,14 @@ async function main() {
// are log lines — this can never block boot.
setupAutoUpdater();
ipcMain.on("lh:update-install", () => installUpdateNow());
ipcMain.handle("lh:update:get", (event) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
return updateState;
});
ipcMain.handle("lh:update:check", (event) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
checkForUpdates({ manual: true });
});
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
@@ -670,13 +732,14 @@ async function main() {
const target = SIDEBAR_AUTOCLOSE;
if (width < target) win.setBounds({ x, y, width: target, height });
});
reopenWindow = () => { chatgpt?.resume(); subscriptions?.resume(); return createWindow(url); };
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
if (BrowserWindow.getAllWindows().length === 0) reopenWindow();
});
createWindow(url);
log(`window open: ${url}`);
// First update check AFTER the window exists (the downloaded-banner
// push needs a listener); dev builds no-op (no autoUpdater).
// Startup + 24h checks stay main-owned and read the current preference.
// No renderer subscription is required; late loads retrieve the snapshot.
if (autoUpdater) {
checkForUpdates();
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
+27 -3
View File
@@ -19,14 +19,25 @@
*
* window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the
* auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes
* {status:"downloaded", version} once an update is staged; installUpdate
* asks main to kill the server and quit-and-install. Browser builds:
* both absent, the banner never renders.
* {status, version, revision} on updater events; installUpdate asks main
* to kill the server and quit-and-install. getUpdateState retrieves the
* snapshot for a late subscriber; checkForUpdates requests a manual
* check (including download/install-on-quit, even with automatic checks OFF).
*
* window.lh.onOpenSettings(cb) — menu navigation, buffered until React
* subscribes after a window is reopened. Browser builds have no bridge.
*
* Channel names are mirrored in electron/main.cjs — keep them in sync.
*/
const { contextBridge, ipcRenderer } = require("electron");
let openSettingsListener = null;
let pendingOpenSettings = null;
ipcRenderer.on("lh:open-settings", (_event, section) => {
if (openSettingsListener) openSettingsListener(section);
else pendingOpenSettings = section;
});
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
chatgpt: {
@@ -71,4 +82,17 @@ contextBridge.exposeInMainWorld("lh", {
return () => ipcRenderer.removeListener("lh:update-status", listener);
},
installUpdate: () => ipcRenderer.send("lh:update-install"),
getUpdateState: () => ipcRenderer.invoke("lh:update:get"),
checkForUpdates: () => ipcRenderer.invoke("lh:update:check"),
onOpenSettings: (cb) => {
openSettingsListener = cb;
if (pendingOpenSettings !== null) {
const section = pendingOpenSettings;
pendingOpenSettings = null;
cb(section);
}
return () => {
if (openSettingsListener === cb) openSettingsListener = null;
};
},
});
+5
View File
@@ -1,6 +1,11 @@
import { readFileSync } from "node:fs";
import type { NextConfig } from "next";
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { version: string; homepage: string };
const nextConfig: NextConfig = {
// Public build metadata only; no new runtime Electron version API.
env: { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage },
// Desktop target (Electron wrapper, 2026-09-17): standalone output = a
// self-contained .next/standalone/server.js the Electron shell spawns
// on 127.0.0.1. Harmless for plain web deploys (extra build output).
+1 -1
View File
@@ -231,7 +231,7 @@ export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
/** General settings — app-level behavior. Version DISPLAY and plans login
* live outside the record (package.json / auth.json) by owner decision. */
export interface GeneralSettings {
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
/** Automatic startup/24h update checks (default ON). Manual checks bypass this. */
autoUpdate?: boolean;
}
+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");
});
});