Apply update
-
+
HQBase verifies the artifact, records the Worker version and D1 bookmark, migrates,
deploys, and verifies before reporting success.
diff --git a/app/features/updates/use-update-monitor.ts b/app/features/updates/use-update-monitor.ts
index 2db96bf..c21cd1e 100644
--- a/app/features/updates/use-update-monitor.ts
+++ b/app/features/updates/use-update-monitor.ts
@@ -1,4 +1,5 @@
import * as React from "react";
+import { PWA_UPDATE_READY_EVENT, readPwaUpdateReady } from "@/features/pwa/update-ready";
import { getUpdateStatus } from "./api";
import type { UpdateStatus } from "./types";
import {
@@ -14,11 +15,13 @@ const activeUpdateIntervalMs = 10_000;
export function useUpdateMonitor(canManage: boolean): {
acceptStatus: (status: UpdateStatus) => void;
progress: UpdateProgress | null;
+ ready: boolean;
start: (buildId: string) => void;
status: UpdateStatus | null;
} {
const [status, setStatus] = React.useState
(null);
const [progress, setProgress] = React.useState(() => readUpdateProgress());
+ const [ready, setReady] = React.useState(readPwaUpdateReady);
const acceptStatus = React.useCallback((nextStatus: UpdateStatus) => {
setStatus(nextStatus);
@@ -28,6 +31,12 @@ export function useUpdateMonitor(canManage: boolean): {
}
}, []);
+ React.useEffect(() => {
+ const handleUpdateReady = (): void => setReady(true);
+ window.addEventListener(PWA_UPDATE_READY_EVENT, handleUpdateReady);
+ return () => window.removeEventListener(PWA_UPDATE_READY_EVENT, handleUpdateReady);
+ }, []);
+
React.useEffect(() => {
if (!canManage) {
setStatus(null);
@@ -72,5 +81,5 @@ export function useUpdateMonitor(canManage: boolean): {
setProgress(beginUpdateProgress(buildId));
}, []);
- return { acceptStatus, progress, start, status };
+ return { acceptStatus, progress, ready, start, status };
}
diff --git a/app/lib/notification-sounds.ts b/app/lib/notification-sounds.ts
index 7680897..7345431 100644
--- a/app/lib/notification-sounds.ts
+++ b/app/lib/notification-sounds.ts
@@ -4,7 +4,8 @@ export type NotificationSound =
| "toast-error"
| "toast-information"
| "toast-success"
- | "toast-warning";
+ | "toast-warning"
+ | "update-ready";
export type ToastSoundType =
| "action"
@@ -25,7 +26,8 @@ const SOUND_SOURCES: Record = {
"toast-error": "/sounds/toast-error.wav",
"toast-information": "/sounds/toast-information.wav",
"toast-success": "/sounds/toast-success.wav",
- "toast-warning": "/sounds/toast-warning.wav"
+ "toast-warning": "/sounds/toast-warning.wav",
+ "update-ready": "/sounds/update-ready.wav"
};
let initialized = false;
diff --git a/package.json b/package.json
index 35c6ed1..0784836 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hqbase",
- "version": "0.1.28",
+ "version": "0.1.29",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
diff --git a/public/sounds/update-ready.wav b/public/sounds/update-ready.wav
new file mode 100644
index 0000000..868e543
Binary files /dev/null and b/public/sounds/update-ready.wav differ
diff --git a/scripts/generate-notification-sounds.mjs b/scripts/generate-notification-sounds.mjs
index 36f8de6..6492f73 100644
--- a/scripts/generate-notification-sounds.mjs
+++ b/scripts/generate-notification-sounds.mjs
@@ -27,6 +27,11 @@ const sounds = {
{ gap: 45 },
{ from: 440, to: 440, duration: 65 }
],
+ "update-ready": [
+ { from: 520, to: 620, duration: 90 },
+ { gap: 40 },
+ { from: 660, to: 820, duration: 150 }
+ ],
unlock: [{ gap: 30 }]
};
diff --git a/test/unit/app/notification-sounds.test.ts b/test/unit/app/notification-sounds.test.ts
index 2a88685..2e930b1 100644
--- a/test/unit/app/notification-sounds.test.ts
+++ b/test/unit/app/notification-sounds.test.ts
@@ -51,8 +51,10 @@ describe("notification sounds", () => {
expect(audio.src).toBe("/sounds/toast-error.wav");
playNotificationSound("toast-information");
expect(audio.src).toBe("/sounds/toast-information.wav");
+ playNotificationSound("update-ready");
+ expect(audio.src).toBe("/sounds/update-ready.wav");
- expect(audio.play).toHaveBeenCalledTimes(7);
+ expect(audio.play).toHaveBeenCalledTimes(8);
expect(audio.volume).toBe(0.55);
});
});
diff --git a/test/unit/app/pwa/pwa-lifecycle.test.tsx b/test/unit/app/pwa/pwa-lifecycle.test.tsx
new file mode 100644
index 0000000..8214d3d
--- /dev/null
+++ b/test/unit/app/pwa/pwa-lifecycle.test.tsx
@@ -0,0 +1,43 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi } from "vitest";
+
+import { PwaLifecycle } from "@/features/pwa/pwa-lifecycle";
+import { flushHookEffects, renderComponent } from "../render-hook";
+
+const mocks = vi.hoisted(() => ({
+ onUpdateReady: null as null | ((update: { activate: () => void }) => void),
+ playNotificationSound: vi.fn(),
+ registerPwa: vi.fn(
+ ({ onUpdateReady }: { onUpdateReady: (update: { activate: () => void }) => void }) => {
+ mocks.onUpdateReady = onUpdateReady;
+ return vi.fn();
+ }
+ )
+}));
+
+vi.mock("@/features/pwa/register", () => ({
+ registerPwa: mocks.registerPwa
+}));
+
+vi.mock("@/lib/notification-sounds", () => ({
+ playNotificationSound: mocks.playNotificationSound
+}));
+
+describe("PWA lifecycle", () => {
+ it("plays one sound when the ready-to-reload notice appears", async () => {
+ vi.stubEnv("PROD", true);
+ const activate = vi.fn();
+ const view = await renderComponent();
+ await flushHookEffects(() => mocks.onUpdateReady?.({ activate }));
+
+ expect(view.container.textContent).toContain("A new version of HQBase is ready.");
+ expect(view.container.textContent).toContain("Reload");
+ expect(mocks.playNotificationSound).toHaveBeenCalledOnce();
+ expect(mocks.playNotificationSound).toHaveBeenCalledWith("update-ready");
+
+ await flushHookEffects(() => mocks.onUpdateReady?.({ activate }));
+ expect(mocks.playNotificationSound).toHaveBeenCalledOnce();
+ await view.unmount();
+ vi.unstubAllEnvs();
+ });
+});
diff --git a/test/unit/app/pwa/register.test.ts b/test/unit/app/pwa/register.test.ts
index ceabc00..cb762d7 100644
--- a/test/unit/app/pwa/register.test.ts
+++ b/test/unit/app/pwa/register.test.ts
@@ -1,9 +1,12 @@
+// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerPwa } from "@/features/pwa/register";
+import { PWA_UPDATE_READY_EVENT } from "@/features/pwa/update-ready";
import { UPDATE_STARTED_EVENT } from "@/features/updates/update-progress";
describe("PWA registration", () => {
afterEach(() => {
+ document.documentElement.removeAttribute("data-hqbase-update-ready");
vi.unstubAllGlobals();
});
@@ -51,4 +54,34 @@ describe("PWA registration", () => {
unregister();
});
+
+ it("announces when the replacement worker is ready", async () => {
+ const onUpdateReady = vi.fn();
+ const readyListener = vi.fn();
+ const waiting = { postMessage: vi.fn() };
+ const registration = {
+ addEventListener: vi.fn(),
+ installing: null,
+ update: vi.fn().mockResolvedValue(undefined),
+ waiting
+ };
+ const serviceWorker = {
+ addEventListener: vi.fn(),
+ controller: {},
+ register: vi.fn().mockResolvedValue(registration),
+ removeEventListener: vi.fn()
+ };
+ vi.stubGlobal("navigator", { onLine: true, serviceWorker });
+ window.addEventListener(PWA_UPDATE_READY_EVENT, readyListener);
+
+ const unregister = registerPwa({ onUpdateReady });
+ await Promise.resolve();
+
+ expect(onUpdateReady).toHaveBeenCalledOnce();
+ expect(readyListener).toHaveBeenCalledOnce();
+ expect(document.documentElement.hasAttribute("data-hqbase-update-ready")).toBe(true);
+
+ unregister();
+ window.removeEventListener(PWA_UPDATE_READY_EVENT, readyListener);
+ });
});
diff --git a/test/unit/app/updates/update-banner.test.tsx b/test/unit/app/updates/update-banner.test.tsx
index 5e100e2..35fc7ba 100644
--- a/test/unit/app/updates/update-banner.test.tsx
+++ b/test/unit/app/updates/update-banner.test.tsx
@@ -11,7 +11,7 @@ describe("update banner", () => {
release: { version: "0.2.0" }
} as UpdateStatus;
const html = renderToStaticMarkup(
- undefined} />
+ undefined} />
);
expect(html).toContain("Update available");
expect(html).toContain("0.2.0");
@@ -21,14 +21,37 @@ describe("update banner", () => {
expect(html).not.toContain("blue-");
});
- it("stays hidden after the update starts", () => {
+ it("becomes an animated progress banner after the update starts", () => {
const status = {
installedVersion: "0.1.0",
available: true,
release: { version: "0.2.0" }
} as UpdateStatus;
const html = renderToStaticMarkup(
- undefined} />
+ undefined} />
+ );
+ expect(html).toContain("Update in progress");
+ expect(html).toContain("animate-spin");
+ expect(html).toContain("View progress");
+ expect(html).not.toContain("Update available");
+ });
+
+ it("keeps persisted update progress visible while status refreshes", () => {
+ const html = renderToStaticMarkup(
+ undefined} />
+ );
+ expect(html).toContain("Update in progress");
+ expect(html).toContain("View progress");
+ });
+
+ it("disappears when the replacement version is ready to reload", () => {
+ 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-settings.test.tsx b/test/unit/app/updates/update-settings.test.tsx
index ca55e72..e7702b4 100644
--- a/test/unit/app/updates/update-settings.test.tsx
+++ b/test/unit/app/updates/update-settings.test.tsx
@@ -60,7 +60,9 @@ describe("update settings", () => {
onUpdateStarted={() => undefined}
/>
);
- expect(html).toContain("Update started");
+ expect(html).toContain("Update in progress");
+ expect(html).toContain("animate-spin");
+ expect(html).toContain("HQBase 0.2.0 is being deployed");
expect(html).toContain("build-123");
expect(html).not.toContain("Install update");
});
diff --git a/test/unit/app/updates/use-update-monitor.test.tsx b/test/unit/app/updates/use-update-monitor.test.tsx
index 748c9c2..26a2797 100644
--- a/test/unit/app/updates/use-update-monitor.test.tsx
+++ b/test/unit/app/updates/use-update-monitor.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
+import { announcePwaUpdateReady } from "@/features/pwa/update-ready";
import type { UpdateStatus } from "@/features/updates/types";
import { useUpdateMonitor } from "@/features/updates/use-update-monitor";
import { flushHookEffects, renderHook } from "../render-hook";
@@ -46,6 +47,9 @@ describe("useUpdateMonitor", () => {
expect(mocks.getUpdateStatus).toHaveBeenCalledOnce();
expect(hook.result.status).toEqual(status);
+ expect(hook.result.ready).toBe(false);
+ await flushHookEffects(announcePwaUpdateReady);
+ expect(hook.result.ready).toBe(true);
await flushHookEffects(() => window.dispatchEvent(new Event("focus")));
expect(mocks.getUpdateStatus).toHaveBeenCalledTimes(2);
@@ -54,5 +58,6 @@ describe("useUpdateMonitor", () => {
await flushHookEffects(() => window.dispatchEvent(new Event("focus")));
expect(mocks.getUpdateStatus).toHaveBeenCalledTimes(2);
await hook.unmount();
+ document.documentElement.removeAttribute("data-hqbase-update-ready");
});
});