From 1f90e73f418a949ec64e3931d92727fbf3056578 Mon Sep 17 00:00:00 2001 From: olegberman Date: Tue, 28 Jul 2026 16:36:27 -0400 Subject: [PATCH] Detect updates without refresh --- CHANGELOG.md | 9 +++ app/app.tsx | 35 +++------ app/components/layout/app-shell.tsx | 7 +- app/features/pwa/pwa-lifecycle.tsx | 6 +- app/features/pwa/register.ts | 39 +++++++++- app/features/settings/settings-page.tsx | 14 +++- app/features/updates/update-banner.tsx | 4 +- app/features/updates/update-progress.ts | 52 +++++++++++++ app/features/updates/update-settings.tsx | 30 +++++--- app/features/updates/use-update-monitor.ts | 76 +++++++++++++++++++ package.json | 2 +- test/e2e/staging/lifecycle.spec.ts | 2 +- test/unit/app/pwa/register.test.ts | 54 +++++++++++++ .../settings/settings-presentation.test.tsx | 3 + test/unit/app/updates/update-banner.test.tsx | 16 +++- test/unit/app/updates/update-progress.test.ts | 47 ++++++++++++ .../unit/app/updates/update-settings.test.tsx | 33 ++++++-- 17 files changed, 380 insertions(+), 49 deletions(-) create mode 100644 app/features/updates/update-progress.ts create mode 100644 app/features/updates/use-update-monitor.ts create mode 100644 test/unit/app/pwa/register.test.ts create mode 100644 test/unit/app/updates/update-progress.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 119360d..4658f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.20 + +- Hide the available-update banner and duplicate install action after Cloudflare accepts the update + build. +- Check for signed releases when HQBase opens, returns to the foreground, and periodically while it + remains open. +- Detect the replacement application worker during an active update and show the reload prompt + without requiring a manual browser refresh. + ## 0.1.19 - Group replies into one inbox conversation using standard email reply headers, while keeping diff --git a/app/app.tsx b/app/app.tsx index 2ebed28..d34fec9 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -16,8 +16,7 @@ import { SettingsPage } from "@/features/settings/settings-page"; import { getSetupStatus } from "@/features/setup/api"; import { SetupPage } from "@/features/setup/setup-page"; import type { SetupStatus } from "@/features/setup/types"; -import { getUpdateStatus } from "@/features/updates/api"; -import type { UpdateStatus } from "@/features/updates/types"; +import { useUpdateMonitor } from "@/features/updates/use-update-monitor"; import { listUsers } from "@/features/users/api"; import type { WorkspaceUser } from "@/features/users/types"; import { playNotificationSound } from "@/lib/notification-sounds"; @@ -38,7 +37,6 @@ export function App(): React.ReactElement { const [search, setSearch] = React.useState(""); const [composeOpen, setComposeOpen] = React.useState(false); const [isLoading, setIsLoading] = React.useState(true); - const [updateStatus, setUpdateStatus] = React.useState(null); const knownIncomingMessageIds = React.useRef | null>(null); const { navigate, route } = useAppRoute(setup?.isComplete); const activeFolder: FolderId = route.kind === "settings" ? "settings" : route.folder; @@ -49,6 +47,8 @@ export function App(): React.ReactElement { () => mailboxes.filter((mailbox) => mailbox.accessLevel !== null), [mailboxes] ); + const canManageUpdates = user?.role === "owner" || user?.role === "admin"; + const updateMonitor = useUpdateMonitor(canManageUpdates); const loadWorkspace = React.useCallback(async (currentUser: CurrentUser) => { const [nextSetup, nextMailboxes] = await Promise.all([getSetupStatus(), listMailboxes()]); @@ -57,14 +57,8 @@ export function App(): React.ReactElement { if (currentUser.role === "owner" || currentUser.role === "admin") { setUsers(await listUsers()); - void getUpdateStatus() - .then(setUpdateStatus) - .catch(() => { - // Update discovery never delays workspace startup. - }); } else { setUsers([]); - setUpdateStatus(null); } }, []); @@ -108,21 +102,6 @@ export function App(): React.ReactElement { }); }, [reloadConversations]); - React.useEffect(() => { - if (!user || (user.role !== "owner" && user.role !== "admin")) return; - const interval = window.setInterval( - () => { - void getUpdateStatus() - .then(setUpdateStatus) - .catch(() => { - // Update discovery must never interrupt mail work. - }); - }, - 6 * 60 * 60 * 1000 - ); - return () => window.clearInterval(interval); - }, [user]); - React.useEffect(() => { knownIncomingMessageIds.current = null; if (!currentUserId) return; @@ -207,7 +186,8 @@ export function App(): React.ReactElement { mailboxes={contentMailboxes} search={search} user={user} - updateStatus={updateStatus} + updateInProgress={updateMonitor.progress !== null} + updateStatus={updateMonitor.status} onOpenUpdates={() => { navigate({ kind: "settings", tab: "updates" }); }} @@ -239,7 +219,10 @@ export function App(): React.ReactElement { users={users} onRefresh={() => void reload()} onTabChange={(tab) => navigate({ kind: "settings", tab })} - updateStatus={updateStatus} + onUpdateStarted={updateMonitor.start} + onUpdateStatusChange={updateMonitor.acceptStatus} + updateProgress={updateMonitor.progress} + updateStatus={updateMonitor.status} /> ) : ( void; onFolderChange: (folder: FolderId) => void; @@ -48,7 +49,11 @@ export function AppShell(props: AppShellProps): React.ReactElement { onSearchChange={props.onSearchChange} onSignedOut={props.onSignedOut} /> - +
{props.children}
diff --git a/app/features/pwa/pwa-lifecycle.tsx b/app/features/pwa/pwa-lifecycle.tsx index 274c872..90c0408 100644 --- a/app/features/pwa/pwa-lifecycle.tsx +++ b/app/features/pwa/pwa-lifecycle.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import { readUpdateProgress } from "@/features/updates/update-progress"; import { type PwaUpdate, registerPwa } from "./register"; export function PwaLifecycle(): React.ReactElement | null { @@ -13,7 +14,10 @@ export function PwaLifecycle(): React.ReactElement | null { window.addEventListener("offline", handleOffline); const unregisterLifecycle = import.meta.env.PROD - ? registerPwa({ onUpdateReady: setUpdate }) + ? registerPwa({ + onUpdateReady: setUpdate, + watchForUpdate: readUpdateProgress() !== null + }) : () => undefined; return () => { diff --git a/app/features/pwa/register.ts b/app/features/pwa/register.ts index d781906..9ebb09e 100644 --- a/app/features/pwa/register.ts +++ b/app/features/pwa/register.ts @@ -1,19 +1,29 @@ +import { UPDATE_STARTED_EVENT } from "@/features/updates/update-progress"; + export type PwaUpdate = { activate: () => void; }; type RegisterPwaOptions = { onUpdateReady: (update: PwaUpdate) => void; + watchForUpdate?: boolean; }; -const UPDATE_INTERVAL_MS = 60 * 60 * 1000; +const UPDATE_INTERVAL_MS = 15 * 60 * 1000; +const ACTIVE_UPDATE_INTERVAL_MS = 10_000; +const ACTIVE_UPDATE_LIFETIME_MS = 30 * 60 * 1000; -export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void { +export function registerPwa({ + onUpdateReady, + watchForUpdate = false +}: RegisterPwaOptions): () => void { if (!("serviceWorker" in navigator)) return () => undefined; let registration: ServiceWorkerRegistration | undefined; let refreshAfterActivation = false; let disposed = false; + let activeUpdateInterval: number | undefined; + let activeUpdateDeadline = 0; const activate = (): void => { if (!registration?.waiting) return; @@ -23,6 +33,7 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void { const announceWaitingWorker = (): void => { if (registration?.waiting && navigator.serviceWorker.controller) { + stopActiveUpdateWatch(); onUpdateReady({ activate }); } }; @@ -34,6 +45,25 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void { }); }; + const startActiveUpdateWatch = (): void => { + activeUpdateDeadline = Date.now() + ACTIVE_UPDATE_LIFETIME_MS; + checkForUpdate(); + if (activeUpdateInterval !== undefined) return; + activeUpdateInterval = window.setInterval(() => { + if (Date.now() >= activeUpdateDeadline) { + stopActiveUpdateWatch(); + return; + } + checkForUpdate(); + }, ACTIVE_UPDATE_INTERVAL_MS); + }; + + function stopActiveUpdateWatch(): void { + if (activeUpdateInterval === undefined) return; + window.clearInterval(activeUpdateInterval); + activeUpdateInterval = undefined; + } + const handleControllerChange = (): void => { if (!refreshAfterActivation) return; refreshAfterActivation = false; @@ -42,11 +72,13 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void { const handleFocus = (): void => checkForUpdate(); const handleOnline = (): void => checkForUpdate(); + const handleUpdateStarted = (): void => startActiveUpdateWatch(); const interval = window.setInterval(checkForUpdate, UPDATE_INTERVAL_MS); navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange); window.addEventListener("focus", handleFocus); window.addEventListener("online", handleOnline); + window.addEventListener(UPDATE_STARTED_EVENT, handleUpdateStarted); void navigator.serviceWorker .register("/service-worker.js", { scope: "/", updateViaCache: "none" }) @@ -54,6 +86,7 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void { if (disposed) return; registration = nextRegistration; announceWaitingWorker(); + if (watchForUpdate) startActiveUpdateWatch(); registration.addEventListener("updatefound", () => { const installing = registration?.installing; if (!installing) return; @@ -69,8 +102,10 @@ export function registerPwa({ onUpdateReady }: RegisterPwaOptions): () => void { return () => { disposed = true; window.clearInterval(interval); + stopActiveUpdateWatch(); navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange); window.removeEventListener("focus", handleFocus); window.removeEventListener("online", handleOnline); + window.removeEventListener(UPDATE_STARTED_EVENT, handleUpdateStarted); }; } diff --git a/app/features/settings/settings-page.tsx b/app/features/settings/settings-page.tsx index 264e579..c888d91 100644 --- a/app/features/settings/settings-page.tsx +++ b/app/features/settings/settings-page.tsx @@ -8,6 +8,7 @@ import { DebugSettings } from "@/features/settings/debug-settings"; import { SettingsSection } from "@/features/settings/settings-section"; import type { SetupStatus } from "@/features/setup/types"; import type { UpdateStatus } from "@/features/updates/types"; +import type { UpdateProgress } from "@/features/updates/update-progress"; import { UpdateSettings } from "@/features/updates/update-settings"; import type { WorkspaceUser } from "@/features/users/types"; import { UserSettings } from "@/features/users/user-settings"; @@ -21,6 +22,9 @@ type SettingsPageProps = { users: WorkspaceUser[]; onRefresh: () => void; onTabChange: (tab: SettingsTabId) => void; + onUpdateStarted: (buildId: string) => void; + onUpdateStatusChange: (status: UpdateStatus) => void; + updateProgress: UpdateProgress | null; updateStatus: UpdateStatus | null; }; @@ -32,6 +36,9 @@ export function SettingsPage({ users, onRefresh, onTabChange, + onUpdateStarted, + onUpdateStatusChange, + updateProgress, updateStatus }: SettingsPageProps): React.ReactElement { return ( @@ -72,7 +79,12 @@ export function SettingsPage({ ) : null} {canManage ? ( - + ) : null} diff --git a/app/features/updates/update-banner.tsx b/app/features/updates/update-banner.tsx index 020c604..c3e6bcc 100644 --- a/app/features/updates/update-banner.tsx +++ b/app/features/updates/update-banner.tsx @@ -4,13 +4,15 @@ import { Button } from "@/components/ui/button"; import type { UpdateStatus } from "./types"; export function UpdateBanner({ + inProgress, status, onOpen }: { + inProgress: boolean; status: UpdateStatus | null; onOpen: () => void; }): React.ReactElement | null { - if (!status?.available) return null; + if (inProgress || !status?.available) return null; return (
(UPDATE_STARTED_EVENT, { detail: progress }) + ); + } + return progress; +} + +export function readUpdateProgress(now = Date.now()): UpdateProgress | null { + const sessionStorage = storage(); + const serialized = sessionStorage?.getItem(storageKey); + if (!serialized) return null; + + try { + const progress = JSON.parse(serialized) as Partial; + if ( + typeof progress.buildId !== "string" || + !progress.buildId || + typeof progress.startedAt !== "number" || + now - progress.startedAt >= progressLifetimeMs + ) { + sessionStorage?.removeItem(storageKey); + return null; + } + return { buildId: progress.buildId, startedAt: progress.startedAt }; + } catch { + sessionStorage?.removeItem(storageKey); + return null; + } +} + +export function clearUpdateProgress(): void { + storage()?.removeItem(storageKey); +} + +function storage(): Storage | null { + if (typeof window === "undefined") return null; + return window.sessionStorage; +} diff --git a/app/features/updates/update-settings.tsx b/app/features/updates/update-settings.tsx index a94a9e3..a1f3f46 100644 --- a/app/features/updates/update-settings.tsx +++ b/app/features/updates/update-settings.tsx @@ -6,20 +6,30 @@ import { CloudflareAuthorizationDialog } from "@/features/settings/cloudflare-au import { SettingsSection } from "@/features/settings/settings-section"; import { applyUpdate, getUpdateStatus } from "./api"; import type { UpdateStatus } from "./types"; +import type { UpdateProgress } from "./update-progress"; export function UpdateSettings({ - initialStatus + initialStatus, + progress, + onStatusChange, + onUpdateStarted }: { initialStatus: UpdateStatus | null; + progress: UpdateProgress | null; + onStatusChange: (status: UpdateStatus) => void; + onUpdateStarted: (buildId: string) => void; }): React.ReactElement { const [status, setStatus] = React.useState(initialStatus); const [checkError, setCheckError] = React.useState(null); const [applyError, setApplyError] = React.useState(null); - const [buildId, setBuildId] = React.useState(null); const [pendingAction, setPendingAction] = React.useState<"check" | "apply" | null>(null); const [authorizationOpen, setAuthorizationOpen] = React.useState(false); const resumedRef = React.useRef(false); + React.useEffect(() => { + setStatus(initialStatus); + }, [initialStatus]); + React.useEffect(() => { if (resumedRef.current) return; const url = new URL(window.location.href); @@ -44,18 +54,20 @@ export function UpdateSettings({ setPendingAction("apply"); void applyUpdate() - .then((result) => setBuildId(result.buildId)) + .then((result) => onUpdateStarted(result.buildId)) .catch((nextError: unknown) => { setApplyError(nextError instanceof Error ? nextError.message : "Update could not start."); }) .finally(() => setPendingAction(null)); - }, []); + }, [onUpdateStarted]); async function check(): Promise { setPendingAction("check"); setCheckError(null); try { - setStatus(await getUpdateStatus()); + const nextStatus = await getUpdateStatus(); + setStatus(nextStatus); + onStatusChange(nextStatus); } catch (nextError) { setCheckError(nextError instanceof Error ? nextError.message : "Update check failed."); } finally { @@ -78,12 +90,12 @@ export function UpdateSettings({ {applyError} ) : null} - {buildId ? ( + {progress ? ( Update started - Cloudflare build {buildId} is running. HQBase remains - available during the build and will reconnect after deployment. + Cloudflare build {progress.buildId} is running. + HQBase remains available during the build and will reconnect after deployment. ) : null} @@ -100,7 +112,7 @@ export function UpdateSettings({ {pendingAction === "check" ? "Checking…" : "Check updates"}
- {status?.available ? ( + {status?.available && !progress ? (
diff --git a/app/features/updates/use-update-monitor.ts b/app/features/updates/use-update-monitor.ts new file mode 100644 index 0000000..2db96bf --- /dev/null +++ b/app/features/updates/use-update-monitor.ts @@ -0,0 +1,76 @@ +import * as React from "react"; +import { getUpdateStatus } from "./api"; +import type { UpdateStatus } from "./types"; +import { + beginUpdateProgress, + clearUpdateProgress, + readUpdateProgress, + type UpdateProgress +} from "./update-progress"; + +const discoveryIntervalMs = 15 * 60 * 1000; +const activeUpdateIntervalMs = 10_000; + +export function useUpdateMonitor(canManage: boolean): { + acceptStatus: (status: UpdateStatus) => void; + progress: UpdateProgress | null; + start: (buildId: string) => void; + status: UpdateStatus | null; +} { + const [status, setStatus] = React.useState(null); + const [progress, setProgress] = React.useState(() => readUpdateProgress()); + + const acceptStatus = React.useCallback((nextStatus: UpdateStatus) => { + setStatus(nextStatus); + if (!nextStatus.available) { + clearUpdateProgress(); + setProgress(null); + } + }, []); + + React.useEffect(() => { + if (!canManage) { + setStatus(null); + return; + } + + let active = true; + let checking = false; + const check = async (): Promise => { + if (checking || document.visibilityState !== "visible") return; + checking = true; + try { + const nextStatus = await getUpdateStatus(); + if (active) acceptStatus(nextStatus); + } catch { + // Update discovery must never interrupt mail work. + } finally { + checking = false; + } + }; + const checkWhenVisible = (): void => { + if (document.visibilityState === "visible") void check(); + }; + + void check(); + const interval = window.setInterval( + () => void check(), + progress ? activeUpdateIntervalMs : discoveryIntervalMs + ); + window.addEventListener("focus", checkWhenVisible); + document.addEventListener("visibilitychange", checkWhenVisible); + + return () => { + active = false; + window.clearInterval(interval); + window.removeEventListener("focus", checkWhenVisible); + document.removeEventListener("visibilitychange", checkWhenVisible); + }; + }, [acceptStatus, canManage, progress]); + + const start = React.useCallback((buildId: string) => { + setProgress(beginUpdateProgress(buildId)); + }, []); + + return { acceptStatus, progress, start, status }; +} diff --git a/package.json b/package.json index d2dde71..fd2847c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.19", + "version": "0.1.20", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/test/e2e/staging/lifecycle.spec.ts b/test/e2e/staging/lifecycle.spec.ts index 583defd..232c4d4 100644 --- a/test/e2e/staging/lifecycle.spec.ts +++ b/test/e2e/staging/lifecycle.spec.ts @@ -126,7 +126,7 @@ test("HQBase web lifecycle remains healthy", async ({ page, request }) => { ) .toEqual({ available: true, version: expectedUpdate }); await expect(async () => { - await page.reload(); + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); await expect(page.getByText("Update available", { exact: true })).toBeVisible({ timeout: 15_000 }); diff --git a/test/unit/app/pwa/register.test.ts b/test/unit/app/pwa/register.test.ts new file mode 100644 index 0000000..ceabc00 --- /dev/null +++ b/test/unit/app/pwa/register.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerPwa } from "@/features/pwa/register"; +import { UPDATE_STARTED_EVENT } from "@/features/updates/update-progress"; + +describe("PWA registration", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("checks immediately and repeatedly after an HQBase update starts", async () => { + const windowListeners = new Map void>(); + const intervals: Array<{ callback: () => void; delay: number }> = []; + const update = vi.fn().mockResolvedValue(undefined); + const registration = { + addEventListener: vi.fn(), + installing: null, + update, + waiting: null + }; + vi.stubGlobal("window", { + addEventListener: vi.fn((name: string, listener: () => void) => { + windowListeners.set(name, listener); + }), + clearInterval: vi.fn(), + location: { reload: vi.fn() }, + removeEventListener: vi.fn(), + setInterval: vi.fn((callback: () => void, delay: number) => { + intervals.push({ callback, delay }); + return intervals.length; + }) + }); + vi.stubGlobal("navigator", { + onLine: true, + serviceWorker: { + addEventListener: vi.fn(), + controller: {}, + register: vi.fn().mockResolvedValue(registration), + removeEventListener: vi.fn() + } + }); + + const unregister = registerPwa({ onUpdateReady: vi.fn() }); + await Promise.resolve(); + windowListeners.get(UPDATE_STARTED_EVENT)?.(); + + expect(update).toHaveBeenCalledOnce(); + const activeInterval = intervals.find(({ delay }) => delay === 10_000); + expect(activeInterval).toBeDefined(); + activeInterval?.callback(); + expect(update).toHaveBeenCalledTimes(2); + + unregister(); + }); +}); diff --git a/test/unit/app/settings/settings-presentation.test.tsx b/test/unit/app/settings/settings-presentation.test.tsx index dfb2a56..e39b42e 100644 --- a/test/unit/app/settings/settings-presentation.test.tsx +++ b/test/unit/app/settings/settings-presentation.test.tsx @@ -204,6 +204,9 @@ describe("settings presentation", () => { users={[]} onRefresh={() => undefined} onTabChange={() => undefined} + onUpdateStarted={() => undefined} + onUpdateStatusChange={() => undefined} + updateProgress={null} /> ); diff --git a/test/unit/app/updates/update-banner.test.tsx b/test/unit/app/updates/update-banner.test.tsx index 0f9c6c7..5e100e2 100644 --- a/test/unit/app/updates/update-banner.test.tsx +++ b/test/unit/app/updates/update-banner.test.tsx @@ -10,7 +10,9 @@ describe("update banner", () => { available: true, release: { version: "0.2.0" } } as UpdateStatus; - const html = renderToStaticMarkup( undefined} />); + const html = renderToStaticMarkup( + undefined} /> + ); expect(html).toContain("Update available"); expect(html).toContain("0.2.0"); expect(html).toContain("Review update"); @@ -18,4 +20,16 @@ describe("update banner", () => { expect(html).toContain("bg-muted/45"); expect(html).not.toContain("blue-"); }); + + it("stays hidden after the update starts", () => { + const status = { + installedVersion: "0.1.0", + available: true, + release: { version: "0.2.0" } + } as UpdateStatus; + const html = renderToStaticMarkup( + undefined} /> + ); + expect(html).toBe(""); + }); }); diff --git a/test/unit/app/updates/update-progress.test.ts b/test/unit/app/updates/update-progress.test.ts new file mode 100644 index 0000000..01ec285 --- /dev/null +++ b/test/unit/app/updates/update-progress.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + beginUpdateProgress, + readUpdateProgress, + UPDATE_STARTED_EVENT +} from "@/features/updates/update-progress"; + +describe("update progress", () => { + let values: Map; + let dispatchEvent: ReturnType; + + beforeEach(() => { + values = new Map(); + dispatchEvent = vi.fn(); + vi.stubGlobal("window", { + dispatchEvent, + sessionStorage: { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("retains and announces an accepted build", () => { + expect(beginUpdateProgress("build-123", 1_000)).toEqual({ + buildId: "build-123", + startedAt: 1_000 + }); + expect(readUpdateProgress(2_000)).toEqual({ + buildId: "build-123", + startedAt: 1_000 + }); + expect(dispatchEvent).toHaveBeenCalledOnce(); + expect(dispatchEvent.mock.calls[0]?.[0]).toMatchObject({ type: UPDATE_STARTED_EVENT }); + }); + + it("expires a stale build marker", () => { + beginUpdateProgress("build-123", 1_000); + expect(readUpdateProgress(31 * 60 * 1_000)).toBeNull(); + expect(values.size).toBe(0); + }); +}); diff --git a/test/unit/app/updates/update-settings.test.tsx b/test/unit/app/updates/update-settings.test.tsx index 1f82a75..ca55e72 100644 --- a/test/unit/app/updates/update-settings.test.tsx +++ b/test/unit/app/updates/update-settings.test.tsx @@ -21,7 +21,7 @@ const availableStatus: UpdateStatus = { describe("update settings", () => { it("does not present an unknown update state as success", () => { - const html = renderToStaticMarkup(); + const html = renderSettings(null); expect(html).toContain("Not checked"); expect(html).not.toContain("Up to date"); expect(html).toContain("Unknown"); @@ -29,7 +29,7 @@ describe("update settings", () => { }); it("opens authorization from the update action without a credential field", () => { - const html = renderToStaticMarkup(); + const html = renderSettings(availableStatus); expect(html).toContain("Install update"); expect(html).not.toContain('href="/api/updates/cloudflare/oauth/start"'); expect(html).not.toContain("Authorize Cloudflare and update"); @@ -45,11 +45,34 @@ describe("update settings", () => { }); it("makes incompatible releases explicit and disables the action", () => { - const html = renderToStaticMarkup( - - ); + const html = renderSettings({ ...availableStatus, compatible: false }); expect(html).toContain("Direct update unavailable"); expect(html).toContain("cannot update directly"); expect(html).toContain('disabled=""'); }); + + it("shows the accepted build without offering to start it again", () => { + const html = renderToStaticMarkup( + undefined} + onUpdateStarted={() => undefined} + /> + ); + expect(html).toContain("Update started"); + expect(html).toContain("build-123"); + expect(html).not.toContain("Install update"); + }); }); + +function renderSettings(status: UpdateStatus | null): string { + return renderToStaticMarkup( + undefined} + onUpdateStarted={() => undefined} + /> + ); +}