fix: honor automatic updates preference and add Settings controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user