From e8e00a52375e93706098114c7785f74c41313cdd Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 10 Oct 2026 14:11:19 +0300 Subject: [PATCH] auto-update wiring: silent download, restart banner, local-feed drill seam electron-updater in the shell (packaged builds only; dev never updates). Checks at startup + every 24h, downloads silently, and the renderer shows a Restart banner once the update is staged (it installs on quit either way). Manual "Check for Updates..." under the app menu answers via dialog. Every updater event routes through the shell log and is caught - the updater can never take the app down (verified live: a feed failure logs three lines and the app runs on). LH_UPDATE_FEED overrides the baked GitHub feed with a generic local server - the pre-release drill: serve latest-mac.yml + zip + blockmap from localhost and a real installed copy updates itself, no release, no public repo. The asar allow-list re-includes electron-updater + its 16-package runtime tree explicitly (gitignore last-match-wins; asar verified at 2.1 MB). The banner rides the preload bridge (onUpdateStatus/installUpdate) and renders nothing in the browser. window.lh typing centralized in sidebar.tsx's declaration. --- app/layout.tsx | 3 + components/sidebar.tsx | 3 + components/update-banner.tsx | 50 ++++++++++++ electron-builder.yml | 27 +++++++ electron/main.cjs | 116 +++++++++++++++++++++++++++- electron/preload.cjs | 12 +++ package-lock.json | 62 ++++++++++++--- package.json | 1 + tests/render-update-banner.test.tsx | 82 ++++++++++++++++++++ 9 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 components/update-banner.tsx create mode 100644 tests/render-update-banner.test.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 6782118..2343a78 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { AppShell, SidebarProvider } from "@/components/sidebar"; import { ActivityProvider } from "@/components/activity-dock"; import { ServerEventsProvider } from "@/components/server-events"; import { AppearanceProvider } from "@/components/settings/appearance"; +import UpdateBanner from "@/components/update-banner"; // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU // Serif (the bundled lh-cmu @font-face in globals.css — no CDN). @@ -32,6 +33,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {/* Auto-update banner (Wave C): fixed card, Electron-only pushes. */} + ); diff --git a/components/sidebar.tsx b/components/sidebar.tsx index ee06757..b552a4a 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -60,6 +60,9 @@ 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; + installUpdate?: () => void; }; } } diff --git a/components/update-banner.tsx b/components/update-banner.tsx new file mode 100644 index 0000000..cd63030 --- /dev/null +++ b/components/update-banner.tsx @@ -0,0 +1,50 @@ +"use client"; + +/** + * 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. + * 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 }; + +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; + return ( +
+
+ LearnHarness {ready.version ?? "update"} is ready + — restart to finish installing. +
+ +
+ ); +} diff --git a/electron-builder.yml b/electron-builder.yml index a79c196..ebbe9f7 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -41,6 +41,33 @@ files: - "!**/*" - electron/**/* - package.json + # Auto-update (Wave C, 2026-10-10): electron-updater + its runtime tree + # re-included explicitly. The negation above excludes node_modules, and + # the shell's bare positives can't re-include them — these explicit + # node_modules positives do (gitignore order: the LAST matching pattern + # wins; verified post-build by listing the asar — see desktop:dist notes). + # Tree fact-checked via each package's declared dependencies: + # electron-updater → fs-extra, js-yaml, lazy-val, lodash.escaperegexp, + # lodash.isequal, semver, tiny-typed-emitter, builder-util-runtime; + # builder-util-runtime → debug, sax; fs-extra → graceful-fs, jsonfile, + # universalify; debug → ms; jsonfile → universalify (again); js-yaml → + # argparse. Keep in sync when electron-updater bumps (npm ls --all). + - node_modules/electron-updater/**/* + - node_modules/fs-extra/**/* + - node_modules/js-yaml/**/* + - node_modules/lazy-val/**/* + - node_modules/lodash.escaperegexp/**/* + - node_modules/lodash.isequal/**/* + - node_modules/semver/**/* + - node_modules/tiny-typed-emitter/**/* + - node_modules/builder-util-runtime/**/* + - node_modules/debug/**/* + - node_modules/sax/**/* + - node_modules/graceful-fs/**/* + - node_modules/jsonfile/**/* + - node_modules/universalify/**/* + - node_modules/ms/**/* + - node_modules/argparse/**/* extraResources: # Copy from the PARENT dir (.next), not .next/standalone itself: # electron-builder hard-excludes a copy's root node_modules and the diff --git a/electron/main.cjs b/electron/main.cjs index 65a42c3..7ef876f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -24,7 +24,7 @@ * would shadow every settings-page key change until restart. */ -const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron"); +const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron"); const { spawn } = require("node:child_process"); const fs = require("node:fs"); const http = require("node:http"); @@ -298,6 +298,104 @@ function createWindow(url) { * lights, is impossible without rebuilding the window). */ const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2]; +/* ── Auto-update (Wave C, owner 2026-10-10: silent download + restart ───── + * banner; checks at startup + every 24h) ───────────────────────────────── + * + * Feed: GitHub Releases (electron-builder.yml publish → app-update.yml, + * baked + signed into the bundle). macOS needs the app signed (it is — + * Developer ID) and updates ride the zip target (Squirrel.Mac); blockmaps + * give differential downloads for free. + * + * Hard rules: + * - DEV NEVER UPDATES: !app.isPackaged skips the updater entirely — the + * dev world runs `next dev` and has no signed bundle. + * - THE UPDATER CAN NEVER TAKE THE APP DOWN: every event and check is + * caught and logged (feed down, disk full, bad signature — all just + * log lines; the app keeps working, update or no update). + * - LH_UPDATE_FEED (env): overrides the baked GitHub feed with a generic + * local server — the pre-release verification drill (serve + * latest-mac.yml + zip + blockmap from any localhost HTTP dir and + * watch a real installed copy find/download/restart). Test-only seam. + * + * Install path: the downloaded update applies on quit (autoInstallOnAppQuit + * — Squirrel.Mac stages natively and swaps on relaunch). The renderer + * banner asks for an immediate restart via "lh:update-install"; we kill + * the child server FIRST — deterministic ordering: the server lives + * inside the .app bundle Squirrel is about to replace (extraResources), + * 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 + +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 checkForUpdates({ manual = false } = {}) { + if (!autoUpdater) return; + 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.` }); + }) + .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) }); + }); +} + +function installUpdateNow() { + // Kill the server BEFORE the quit-and-install (see block comment). + try { + killServer(); + } catch { + /* already dead */ + } + autoUpdater?.quitAndInstall(); +} + +function setupAutoUpdater() { + if (!app.isPackaged) { + log("auto-update: dev build — updater disabled"); + return; + } + try { + autoUpdater = require("electron-updater").autoUpdater; + } catch (err) { + log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`); + return; + } + const feed = process.env.LH_UPDATE_FEED; + if (feed) { + autoUpdater.setFeedURL({ provider: "generic", url: feed }); + log(`auto-update: LH_UPDATE_FEED override → ${feed}`); + } + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + // Route every updater log line through the shell's own log (userData/ + // logs/main.log) — feed failures must be diagnosable after the fact. + autoUpdater.logger = { + info: (m) => log(`updater: ${m}`), + warn: (m) => log(`updater warn: ${m}`), + debug: (m) => {}, // too chatty for the persistent log + error: (m) => log(`updater error: ${m}`), + }; + autoUpdater.on("update-downloaded", (info) => { + log(`update downloaded: ${info?.version} — banner shown`); + broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null }); + }); + autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`)); +} + function zoomStep(win, dir) { const current = win.webContents.getZoomFactor(); const next = @@ -321,6 +419,9 @@ 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 }) }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, @@ -393,6 +494,13 @@ async function main() { installMenu(); + // Auto-update (Wave C): wire the updater before the window so the + // banner channel is live from first paint; the first check and the 24h + // interval fire after it below. setupAutoUpdater's contract: failures + // are log lines — this can never block boot. + setupAutoUpdater(); + ipcMain.on("lh:update-install", () => installUpdateNow()); + // Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes // the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that // width asks the window to widen instead — the layout never has to @@ -413,4 +521,10 @@ async function main() { }); 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). + if (autoUpdater) { + checkForUpdates(); + setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS); + } } \ No newline at end of file diff --git a/electron/preload.cjs b/electron/preload.cjs index 7c4926f..421763f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -14,6 +14,12 @@ * publishes it as --lh-zoom so physical-constant lengths (the traffic- * light gutter) can divide by it. * + * 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. + * * Channel names are mirrored in electron/main.cjs — keep them in sync. */ const { contextBridge, ipcRenderer } = require("electron"); @@ -26,4 +32,10 @@ contextBridge.exposeInMainWorld("lh", { return () => ipcRenderer.removeListener("lh:zoom", listener); }, getZoom: () => ipcRenderer.invoke("lh:zoom:get"), + onUpdateStatus: (cb) => { + const listener = (_event, status) => cb(status); + ipcRenderer.on("lh:update-status", listener); + return () => ipcRenderer.removeListener("lh:update-status", listener); + }, + installUpdate: () => ipcRenderer.send("lh:update-install"), }); diff --git a/package-lock.json b/package-lock.json index 460fd7d..179d16f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "learning-harness", + "name": "learnharness", "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "learning-harness", + "name": "learnharness", "version": "0.2.0", + "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.0.2", "@earendil-works/pi-ai": "1.0.2", @@ -27,6 +28,7 @@ "d3-shape": "^3.2.0", "d3-zoom": "^3.0.0", "dagre-d3-es": "7.0.14", + "electron-updater": "^6.8.9", "embla-carousel-react": "^8.6.0", "katex": "^0.16.47", "linkedom": "^0.18.13", @@ -6398,7 +6400,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -6782,7 +6783,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -8590,6 +8590,34 @@ "mime": "^2.5.2" } }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -9735,7 +9763,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -10150,7 +10177,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/hachure-fill": { @@ -11339,7 +11365,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -11426,7 +11451,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -11497,7 +11521,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -11837,6 +11860,19 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -15184,7 +15220,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -15938,6 +15973,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", @@ -16456,7 +16497,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 7d8185b..20808a0 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "d3-shape": "^3.2.0", "d3-zoom": "^3.0.0", "dagre-d3-es": "7.0.14", + "electron-updater": "^6.8.9", "embla-carousel-react": "^8.6.0", "katex": "^0.16.47", "linkedom": "^0.18.13", diff --git a/tests/render-update-banner.test.tsx b/tests/render-update-banner.test.tsx new file mode 100644 index 0000000..0a9c307 --- /dev/null +++ b/tests/render-update-banner.test.tsx @@ -0,0 +1,82 @@ +/** + * 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 + * 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 + * render-sidebar.test.tsx). + */ +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import * as ReactDOMClient from "react-dom/client"; +import { flushSync } from "react-dom"; +import { parseHTML } from "linkedom"; +import UpdateBanner from "../components/update-banner.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). +// The window.lh bridge type is declared globally by components/sidebar.tsx. +const dom = parseHTML(""); +(globalThis as Record).window = dom.window; +(globalThis as Record).document = dom.document; + +describe("update banner (Wave C)", () => { + afterEach(() => { + delete (window as { lh?: unknown }).lh; + }); + + it("renders nothing with no bridge (browser / SSR)", () => { + const html = renderToStaticMarkup(); + assert.equal(html, ""); + }); + + it("renders nothing when the bridge exists but no status has arrived", () => { + const listeners: Array<(s: { status: string; version: string | null }) => void> = []; + (window as { lh?: unknown }).lh = { + onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => { + listeners.push(cb); + return () => {}; + }, + installUpdate: () => {}, + }; + const html = renderToStaticMarkup(); + assert.equal(html, ""); + void listeners; + }); + + 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 installed = 0; + (window as { lh?: unknown }).lh = { + onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => { + pushed = cb; + return () => {}; + }, + installUpdate: () => { + installed += 1; + }, + }; + const host = document.createElement("div"); + document.body.appendChild(host); + const root = ReactDOMClient.createRoot(host); + flushSync(() => root.render()); + assert.equal(host.innerHTML, "", "hidden before the push"); + + await new Promise((r) => setTimeout(r, 0)); + flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1" })); + assert.ok(host.innerHTML.includes("0.3.1"), "the version renders"); + assert.ok(host.innerHTML.includes("Restart"), "the restart affordance renders"); + + const button = host.querySelector("[data-testid='update-banner'] button"); + assert.ok(button, "restart button exists"); + button!.click(); + assert.equal(installed, 1, "Restart asks the shell to quit-and-install"); + root.unmount(); + host.remove(); + }); +});