diff --git a/components/settings/controls.tsx b/components/settings/controls.tsx index c7ee2f6..4aaa385 100644 --- a/components/settings/controls.tsx +++ b/components/settings/controls.tsx @@ -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 diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 1142bdf..9145e0c 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -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 ( {children} @@ -362,19 +360,12 @@ export function SettingsDialogBody({ title="General" testid="section-general" > - - - void write("general", { autoUpdate: on }) - } - /> - + + void write("general", { ...record.general, autoUpdate: on }) + } + /> )} {active === "keys" && ( diff --git a/components/settings/update-settings.tsx b/components/settings/update-settings.tsx new file mode 100644 index 0000000..71b948d --- /dev/null +++ b/components/settings/update-settings.tsx @@ -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(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 ( + <> + + {status && {status}} + {active && {MANUAL_UPDATE_HINT}} + {releaseNotes && Release notes} + + } + > + {active && (state.status === "downloaded" ? ( + + ) : ( + + ))} + + {active && ( + + + + )} + + ); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index d853f5c..6ca9c21 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -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; - /** 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; + /** Explicit manual check also downloads and stages available updates. */ + checkForUpdates?: () => Promise; installUpdate?: () => void; + onOpenSettings?: (cb: (section: string) => void) => () => void; }; } } diff --git a/components/update-banner.tsx b/components/update-banner.tsx index cd63030..a769ec2 100644 --- a/components/update-banner.tsx +++ b/components/update-banner.tsx @@ -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(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 (
{ - // 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); diff --git a/electron/preload.cjs b/electron/preload.cjs index 4912005..9294291 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -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; + }; + }, }); diff --git a/next.config.ts b/next.config.ts index db9352e..94193d8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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). diff --git a/src/core/settings.ts b/src/core/settings.ts index 4daece8..ce80c65 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -231,7 +231,7 @@ export const ROLE_SLOT: Readonly> = { /** 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; } diff --git a/tests/automatic-updates.test.ts b/tests/automatic-updates.test.ts new file mode 100644 index 0000000..5875a7e --- /dev/null +++ b/tests/automatic-updates.test.ts @@ -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 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); + }); +}); diff --git a/tests/desktop-platform.test.ts b/tests/desktop-platform.test.ts index c72e633..040a47a 100644 --- a/tests/desktop-platform.test.ts +++ b/tests/desktop-platform.test.ts @@ -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; + checkForUpdates: () => Promise; + }; + const handlers = new Map 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 }; const config = yaml.load(readFileSync(new URL("../electron-builder.yml", import.meta.url), "utf8")); diff --git a/tests/render-update-banner.test.tsx b/tests/render-update-banner.test.tsx index 0a9c307..a7207df 100644 --- a/tests/render-update-banner.test.tsx +++ b/tests/render-update-banner.test.tsx @@ -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"); diff --git a/tests/render-update-settings.test.tsx b/tests/render-update-settings.test.tsx new file mode 100644 index 0000000..7c01467 --- /dev/null +++ b/tests/render-update-settings.test.tsx @@ -0,0 +1,223 @@ +/** Update UI with a fake preload bridge: no Electron, network or updates. */ +import React, { act } from "react"; +(globalThis as Record).React = React; +(globalThis as Record).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(""); +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 => ({ 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(`[data-testid='${id}']`); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((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(); + 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(); + 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( 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(); + 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(); + 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(); + 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(); + 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(<>); + 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(); const b = bridge(state(), snapshot.promise); + const host = await mount(<>); + 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(); const b = bridge(state(), snapshot.promise); + const host = await mount(); + 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(); bridge(state(), snapshot.promise); + const host = await mount(); + 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( {}} />); + 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(app); + 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")); + }); +}); diff --git a/tests/tooltip-mechanism.test.tsx b/tests/tooltip-mechanism.test.tsx index 653cfe6..b83d834 100644 --- a/tests/tooltip-mechanism.test.tsx +++ b/tests/tooltip-mechanism.test.tsx @@ -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"); }); });