diff --git a/apps/bot/src/scheduler/fire-reminder.ts b/apps/bot/src/scheduler/fire-reminder.ts index ba57cfa..764d71e 100644 --- a/apps/bot/src/scheduler/fire-reminder.ts +++ b/apps/bot/src/scheduler/fire-reminder.ts @@ -11,6 +11,7 @@ import { writeAuditLog } from "../audit.js"; import { getReminderWithDetails } from "../reminders/crud.js"; import { getBoss } from "./pgboss-client.js"; import { scheduleReminderFire } from "./reminder-jobs.js"; +import { pgNotifyWeb } from "../ipc/notify.js"; export type FireReminderPayload = { reminderId: string }; @@ -157,6 +158,17 @@ export async function fireReminder(payload: FireReminderPayload): Promise .set({ status }) .where(eq(reminderRuns.id, runId)); + // Notify the web so any open browsers can fire a notification. + // The web UI subscribes to `reminder.fired` via SSE and surfaces + // it as a desktop / mobile notification when the operator has + // opted in (Notification.permission === "granted"). + await pgNotifyWeb({ + type: "reminder.fired", + reminderId: reminder.id, + runId, + status, + }); + // One-off reminders end after firing. Recurring reminders compute the // next occurrence from the RRULE and re-arm the pg-boss job; only the // last fire timestamp + updatedAt move forward. diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e3bf223..bb240a5 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { GeistSans } from "geist/font/sans"; import { ThemeProvider } from "@/components/theme-provider"; import { AppShell } from "@/components/app-shell"; +import { NotificationManager } from "@/components/notification-manager"; import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; @@ -46,6 +47,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + {/* SSE → browser notification bridge. Renders no DOM. */} + diff --git a/apps/web/src/app/manifest.webmanifest/route.test.ts b/apps/web/src/app/manifest.webmanifest/route.test.ts new file mode 100644 index 0000000..3ca103a --- /dev/null +++ b/apps/web/src/app/manifest.webmanifest/route.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { GET } from "./route"; + +/** + * Contract test for the PWA manifest. Most of these fields drive + * platform behaviour at install time: + * + * - Safari uses `name` and `apple-touch-icon.png` for the home + * screen tile, + * - Android Chrome uses `start_url` + `display: standalone` to + * decide whether to launch in fullscreen, + * - The `purpose: "any maskable"` icons let Android adaptive + * launchers crop without visual breakage. + * + * If any of these flip we want the test to fail loudly rather than + * have a silent change in install behaviour ship. + */ + +describe("/manifest.webmanifest GET", () => { + it("responds with JSON content-type", async () => { + const res = GET(); + expect(res.headers.get("content-type")).toMatch(/application\/json/); + }); + + it("declares the standalone display mode and home start URL", async () => { + const res = GET(); + const body = (await res.json()) as Record; + expect(body.display).toBe("standalone"); + expect(body.start_url).toBe("/"); + expect(body.scope).toBe("/"); + expect(body.orientation).toBe("portrait"); + }); + + it("carries the brand name + short_name + description", async () => { + const body = (await GET().json()) as Record; + expect(body.name).toBe("cm WhatsApp Bot"); + expect(body.short_name).toBe("cm WA Bot"); + expect(body.description).toBe("Self-hosted WhatsApp reminder bot"); + }); + + it("uses the dark theme + matching background colors", async () => { + const body = (await GET().json()) as Record; + expect(body.theme_color).toBe("#0a0a0a"); + expect(body.background_color).toBe("#0a0a0a"); + }); + + it("ships a 192 + 512 icon pair, both PNG, both 'any maskable'", async () => { + const body = (await GET().json()) as { icons: Array<{ + src: string; + sizes: string; + type: string; + purpose?: string; + }> }; + expect(body.icons).toHaveLength(2); + + const i192 = body.icons.find((i) => i.sizes === "192x192"); + const i512 = body.icons.find((i) => i.sizes === "512x512"); + expect(i192).toBeDefined(); + expect(i512).toBeDefined(); + + for (const icon of body.icons) { + expect(icon.type).toBe("image/png"); + // "any maskable" lets Android launchers crop the icon to their + // adaptive shape without exposing transparent bezels. + expect(icon.purpose).toBe("any maskable"); + expect(icon.src.startsWith("/")).toBe(true); + } + }); + + it("matches the icon files actually committed under public/", async () => { + const body = (await GET().json()) as { icons: Array<{ src: string }> }; + // Cross-check: the manifest claims paths the build pipeline must + // serve. If someone removes one of these PNGs without removing + // the manifest entry, install pages on Android break silently. + const srcs = body.icons.map((i) => i.src); + expect(srcs).toContain("/icon-192.png"); + expect(srcs).toContain("/icon-512.png"); + }); +}); diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 871aeaa..cdcef81 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -1,7 +1,8 @@ import { getSeededOperator } from "@/lib/operator"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { ThemeToggle } from "@/components/theme-toggle"; +import { NotificationsToggle } from "@/components/notifications-toggle"; export default async function SettingsPage() { const op = await getSeededOperator(); @@ -24,6 +25,20 @@ export default async function SettingsPage() { + + + Notifications + + Browser notifications when a reminder fires successfully or a + test message is sent. Uses the in-tab Notification API — works + while the app is open. Background push is on the roadmap. + + + + + + + Appearance diff --git a/apps/web/src/components/notification-manager.tsx b/apps/web/src/components/notification-manager.tsx new file mode 100644 index 0000000..0be68f4 --- /dev/null +++ b/apps/web/src/components/notification-manager.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useEvents } from "@/hooks/use-events"; +import { + reminderFiredToNotification, + sendTestDoneToNotification, + showNotification, +} from "@/lib/notifications"; + +/** + * Mounted once at the app shell. Listens for the bot's `reminder.fired` + * and `send_test.done` SSE events and surfaces them as browser + * notifications when the operator has opted in (Notification.permission + * === "granted" + their localStorage opt-in flag). + * + * Renders no DOM — it's a side-effect carrier so the SSE subscription + * lives at app-shell level instead of being duplicated on every page + * that wants to know about reminders firing. + */ +export function NotificationManager() { + useEvents({ + "reminder.fired": (event) => { + const args = reminderFiredToNotification({ type: "reminder.fired", ...event }); + if (args) showNotification(args); + }, + "send_test.done": (event) => { + const args = sendTestDoneToNotification({ type: "send_test.done", ...event }); + if (args) showNotification(args); + }, + }); + return null; +} diff --git a/apps/web/src/components/notifications-toggle.tsx b/apps/web/src/components/notifications-toggle.tsx new file mode 100644 index 0000000..ae6c941 --- /dev/null +++ b/apps/web/src/components/notifications-toggle.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BellIcon, BellOffIcon, AlertCircleIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + getPermission, + isOptedIn, + notificationSupport, + requestPermission, + setOptedIn, + showNotification, + type NotificationPermission, +} from "@/lib/notifications"; + +/** + * Settings-page card that lets the operator opt into browser + * notifications for "reminder fired" and "send-test sent" events. + * + * Three states the UI surfaces: + * + * 1. Not supported (server / older browser) — shows a one-line + * muted note. No action. + * 2. Permission not granted — Enable button asks the browser. + * If the user blocks the prompt we fall back to a + * "blocked in browser" note pointing them at site settings. + * 3. Granted + opted in — toggle to disable, plus a "Test" + * button that fires a sample notification so the operator + * knows the wiring works end-to-end. + */ +export function NotificationsToggle() { + const [supported, setSupported] = useState(false); + const [permission, setPermission] = useState("default"); + const [optedIn, setOptedInState] = useState(false); + const [busy, setBusy] = useState(false); + + // Hydrate from the live browser state once on mount; the server + // can't know any of these values. + useEffect(() => { + setSupported(notificationSupport() === "supported"); + setPermission(getPermission()); + setOptedInState(isOptedIn()); + }, []); + + async function enable() { + setBusy(true); + const result = await requestPermission(); + setPermission(result); + if (result === "granted") { + setOptedIn(true); + setOptedInState(true); + } + setBusy(false); + } + + function disable() { + setOptedIn(false); + setOptedInState(false); + } + + function fireTest() { + showNotification({ + title: "Test notification", + body: "Notifications are wired up. Reminder runs and send-tests will surface here.", + tag: "settings-test", + }); + } + + if (!supported) { + return ( +
+ + Notifications aren't supported by this browser. +
+ ); + } + + if (permission === "denied") { + return ( +
+ + + Notifications are blocked at the browser level. Enable them + in your site settings (lock icon next to the URL) and + reload. + +
+ ); + } + + if (permission === "granted" && optedIn) { + return ( +
+ + + On + + + +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/lib/notifications.test.ts b/apps/web/src/lib/notifications.test.ts new file mode 100644 index 0000000..c1e85af --- /dev/null +++ b/apps/web/src/lib/notifications.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + getPermission, + isOptedIn, + notificationSupport, + reminderFiredToNotification, + sendTestDoneToNotification, + setOptedIn, + showNotification, + type NotificationPermission, +} from "./notifications"; + +// --------------------------------------------------------------------------- +// Fake browser environment +// --------------------------------------------------------------------------- +// vitest's default node env has no `window`. We install a minimal shim +// before each test (and tear it down afterwards) so the helpers under +// test can run as if they were in a browser tab. + +interface FakeStore { + data: Map; +} + +function installWindow(opts: { + permission?: NotificationPermission; + notificationsEnabled?: boolean; + ctorThrows?: boolean; +}) { + const calls: Array<{ title: string; init: NotificationOptions | undefined }> = []; + const store: FakeStore = { data: new Map() }; + + class FakeNotification { + static permission: NotificationPermission = opts.permission ?? "default"; + static async requestPermission(): Promise { + // Default test policy: granting on request unless the test + // overrides Notification.permission directly. + FakeNotification.permission = "granted"; + return "granted"; + } + onclick: (() => void) | null = null; + constructor(public title: string, public init?: NotificationOptions) { + if (opts.ctorThrows) throw new Error("ctor failure"); + calls.push({ title, init }); + } + } + + const fakeWindow = { + Notification: opts.notificationsEnabled === false ? undefined : FakeNotification, + localStorage: { + getItem: (k: string) => store.data.get(k) ?? null, + setItem: (k: string, v: string) => { + store.data.set(k, v); + }, + removeItem: (k: string) => { + store.data.delete(k); + }, + }, + location: { href: "/" }, + focus: vi.fn(), + }; + + // @ts-expect-error — augment globalThis at runtime + globalThis.window = fakeWindow; + return { calls, FakeNotification, store }; +} + +function tearDown() { + // @ts-expect-error — clean up the shim + delete globalThis.window; +} + +beforeEach(() => { + // Make sure no shim leaks across tests. + tearDown(); +}); +afterEach(tearDown); + +// --------------------------------------------------------------------------- +// notificationSupport / getPermission +// --------------------------------------------------------------------------- +describe("notificationSupport / getPermission", () => { + it("reports unsupported when window is missing (SSR)", () => { + expect(notificationSupport()).toBe("unsupported"); + expect(getPermission()).toBe("denied"); + }); + + it("reports unsupported when the browser has no Notification API", () => { + installWindow({ notificationsEnabled: false }); + expect(notificationSupport()).toBe("unsupported"); + expect(getPermission()).toBe("denied"); + }); + + it("returns the live permission when supported", () => { + installWindow({ permission: "granted" }); + expect(notificationSupport()).toBe("supported"); + expect(getPermission()).toBe("granted"); + }); +}); + +// --------------------------------------------------------------------------- +// isOptedIn / setOptedIn — localStorage flag +// --------------------------------------------------------------------------- +describe("opt-in localStorage flag", () => { + it("defaults to opted-out", () => { + installWindow({ permission: "granted" }); + expect(isOptedIn()).toBe(false); + }); + + it("flips to opted-in after setOptedIn(true)", () => { + installWindow({ permission: "granted" }); + setOptedIn(true); + expect(isOptedIn()).toBe(true); + }); + + it("setOptedIn(false) removes the flag", () => { + installWindow({ permission: "granted" }); + setOptedIn(true); + setOptedIn(false); + expect(isOptedIn()).toBe(false); + }); + + it("survives gracefully when window is missing", () => { + expect(() => setOptedIn(true)).not.toThrow(); + expect(isOptedIn()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// showNotification — gating + dispatch +// --------------------------------------------------------------------------- +describe("showNotification gating", () => { + it("returns 'unsupported' when there's no Notification API", () => { + installWindow({ notificationsEnabled: false }); + setOptedIn(true); + const r = showNotification({ title: "x" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("unsupported"); + }); + + it("returns 'opted-out' when the operator hasn't opted in", () => { + installWindow({ permission: "granted" }); + const r = showNotification({ title: "x" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("opted-out"); + }); + + it("returns 'permission' when permission is default / not yet granted", () => { + installWindow({ permission: "default" }); + setOptedIn(true); + const r = showNotification({ title: "x" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("permission"); + }); + + it("returns 'permission' when blocked by the browser", () => { + installWindow({ permission: "denied" }); + setOptedIn(true); + const r = showNotification({ title: "x" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("permission"); + }); + + it("dispatches when supported + opted in + granted, and forwards body/tag/icon", () => { + const { calls } = installWindow({ permission: "granted" }); + setOptedIn(true); + const r = showNotification({ + title: "Reminder sent", + body: "All groups received the message.", + tag: "reminder:r-1", + }); + expect(r.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]!.title).toBe("Reminder sent"); + expect(calls[0]!.init?.body).toBe("All groups received the message."); + expect(calls[0]!.init?.tag).toBe("reminder:r-1"); + // Defaults to /icon-192.png + /icon-192.png for badge. + expect(calls[0]!.init?.icon).toBe("/icon-192.png"); + expect(calls[0]!.init?.badge).toBe("/icon-192.png"); + }); + + it("returns 'error' when the Notification ctor throws (rare)", () => { + installWindow({ permission: "granted", ctorThrows: true }); + setOptedIn(true); + const r = showNotification({ title: "x" }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("error"); + expect(r.error).toMatch(/ctor failure/); + } + }); +}); + +// --------------------------------------------------------------------------- +// Event mappers +// --------------------------------------------------------------------------- +describe("reminderFiredToNotification mapping", () => { + it("renders 'success' as a positive 'sent' notification", () => { + const args = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-1", + runId: "run-1", + status: "success", + }); + expect(args).not.toBeNull(); + expect(args?.title).toBe("Reminder sent"); + expect(args?.body).toMatch(/All groups received/); + expect(args?.tag).toBe("reminder:r-1"); + expect(args?.href).toBe("/reminders/r-1"); + }); + + it("renders 'partial' with a hint to open Activity", () => { + const args = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-2", + runId: "run-2", + status: "partial", + }); + expect(args?.title).toBe("Reminder partly sent"); + expect(args?.body).toMatch(/Some groups received/); + expect(args?.body).toMatch(/See activity/); + }); + + it("renders 'failed' with a clear failure headline", () => { + const args = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-3", + runId: "run-3", + status: "failed", + }); + expect(args?.title).toBe("Reminder failed"); + }); + + it("returns null for 'skipped' (bookkeeping noise, not user-facing)", () => { + const args = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-4", + runId: "run-4", + status: "skipped", + }); + expect(args).toBeNull(); + }); + + it("uses the same tag for repeat fires of the same reminder so they coalesce", () => { + const a = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-1", + runId: "run-A", + status: "success", + }); + const b = reminderFiredToNotification({ + type: "reminder.fired", + reminderId: "r-1", + runId: "run-B", + status: "success", + }); + expect(a?.tag).toBe(b?.tag); + }); +}); + +describe("sendTestDoneToNotification mapping", () => { + it("renders ok=true as 'Test message sent' linked to the group", () => { + const args = sendTestDoneToNotification({ + type: "send_test.done", + groupId: "g-1", + ok: true, + }); + expect(args?.title).toBe("Test message sent"); + expect(args?.tag).toBe("send-test:g-1"); + expect(args?.href).toBe("/groups/g-1"); + }); + + it("returns null on failure (toast already shows the error)", () => { + const args = sendTestDoneToNotification({ + type: "send_test.done", + groupId: "g-1", + ok: false, + }); + expect(args).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/notifications.ts b/apps/web/src/lib/notifications.ts new file mode 100644 index 0000000..37cbd63 --- /dev/null +++ b/apps/web/src/lib/notifications.ts @@ -0,0 +1,182 @@ +/** + * Browser Notification helper. Pure-ish wrapper around the Web + * Notification API plus a localStorage flag for the operator's + * opt-in preference. We use this for surfaced, in-tab notifications + * — when a tab is open, fire a notification on: + * + * - reminder.fired (status=success / partial) + * - send_test.done (ok=true) + * + * For background push (closed app) we'd need a service-worker + * subscription + VAPID, which is a bigger lift. This module is the + * foundation that swap-in lands on later. + */ + +const STORAGE_KEY = "cmbot.notifications.optedIn"; + +export type NotificationPermission = "default" | "granted" | "denied"; +export type NotificationCapability = "supported" | "unsupported"; + +/** Detect whether the browser exposes the Notification API at all. */ +export function notificationSupport(): NotificationCapability { + if (typeof window === "undefined") return "unsupported"; + if (typeof window.Notification === "undefined") return "unsupported"; + return "supported"; +} + +/** Read the current permission state, treating unsupported browsers + * as "denied" so callers don't have to handle a third state. */ +export function getPermission(): NotificationPermission { + if (notificationSupport() === "unsupported") return "denied"; + return window.Notification.permission as NotificationPermission; +} + +/** Has the operator opted in (saved in localStorage)? */ +export function isOptedIn(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +export function setOptedIn(value: boolean): void { + if (typeof window === "undefined") return; + try { + if (value) { + window.localStorage.setItem(STORAGE_KEY, "1"); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } + } catch { + // localStorage can throw in private mode / quota — non-fatal. + } +} + +/** + * Ask the browser for permission. Resolves with the resulting + * permission state. Idempotent if already granted/denied. + */ +export async function requestPermission(): Promise { + if (notificationSupport() === "unsupported") return "denied"; + const result = await window.Notification.requestPermission(); + return result as NotificationPermission; +} + +export interface ShowNotificationOptions { + title: string; + body?: string; + /** Stable identifier so repeats of the same event coalesce. */ + tag?: string; + /** Path the click handler should navigate to (page reuses or + * opens a new tab). Optional. */ + href?: string; + /** Override the default icon; defaults to `/icon-192.png`. */ + icon?: string; +} + +export type NotificationDispatch = + | { ok: true; tag: string | undefined } + | { ok: false; reason: "unsupported" | "permission" | "opted-out" | "error"; error?: string }; + +/** + * Show a notification if everything's lined up: + * - browser supports it + * - operator has opted in + * - permission is granted + * + * Returns a discriminated result so callers can decide whether to + * fall back to a UI toast. + */ +export function showNotification(opts: ShowNotificationOptions): NotificationDispatch { + if (notificationSupport() === "unsupported") { + return { ok: false, reason: "unsupported" }; + } + if (!isOptedIn()) { + return { ok: false, reason: "opted-out" }; + } + if (getPermission() !== "granted") { + return { ok: false, reason: "permission" }; + } + try { + const n = new window.Notification(opts.title, { + body: opts.body, + tag: opts.tag, + icon: opts.icon ?? "/icon-192.png", + badge: "/icon-192.png", + }); + if (opts.href) { + n.onclick = () => { + try { + window.focus(); + window.location.href = opts.href!; + } catch { + // ignore — focus can fail under iframe sandboxing + } + }; + } + return { ok: true, tag: opts.tag }; + } catch (err) { + return { ok: false, reason: "error", error: (err as Error).message }; + } +} + +// --------------------------------------------------------------------------- +// Event → notification mapping +// --------------------------------------------------------------------------- + +/** + * Map a `reminder.fired` SSE event into the notification arguments + * the manager component should call `showNotification` with. + * + * Returns null when the run isn't worth notifying about (e.g. + * `skipped` runs are bookkeeping noise, not user-facing events). + */ +export function reminderFiredToNotification(event: { + type: "reminder.fired"; + reminderId: string; + runId: string; + status: string; +}): ShowNotificationOptions | null { + if (event.status === "skipped") return null; + const headline = + event.status === "success" + ? "Reminder sent" + : event.status === "partial" + ? "Reminder partly sent" + : "Reminder failed"; + const body = + event.status === "success" + ? "All groups received the message." + : event.status === "partial" + ? "Some groups received the message; others failed. See activity." + : "No groups received the message. See activity."; + return { + title: headline, + body, + // Coalesce repeat fires of the same reminder on the same tab — + // only the most recent one stays visible. + tag: `reminder:${event.reminderId}`, + href: `/reminders/${event.reminderId}`, + }; +} + +/** + * Map a `send_test.done` SSE event into notification arguments. + * Returns null on the failure case so the in-page toast (which + * shows the error verbatim) stays the source of truth. + */ +export function sendTestDoneToNotification(event: { + type: "send_test.done"; + groupId: string; + ok: boolean; +}): ShowNotificationOptions | null { + if (!event.ok) return null; + return { + title: "Test message sent", + body: "WhatsApp delivered the test message.", + tag: `send-test:${event.groupId}`, + href: `/groups/${event.groupId}`, + }; +} diff --git a/apps/web/src/pwa/config.test.ts b/apps/web/src/pwa/config.test.ts new file mode 100644 index 0000000..bec0095 --- /dev/null +++ b/apps/web/src/pwa/config.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { serviceWorkerConfig } from "./config"; + +/** + * Pin the service-worker config choices that ship to production. If + * any of these flip without an intentional change, the test fails so + * the regression shows up in CI rather than in a "why don't my + * service-worker updates land" bug report a week later. + */ + +describe("serviceWorkerConfig", () => { + const stockManifest = [ + "/", + { url: "/icon-192.png", revision: "abc123" }, + { url: "/icon-512.png", revision: "def456" }, + ]; + const stockRuntimeCaching = [{ stub: "default" }]; + + it("forwards the precache manifest unchanged", () => { + const cfg = serviceWorkerConfig(stockManifest, stockRuntimeCaching); + expect(cfg.precacheEntries).toBe(stockManifest); + }); + + it("forwards the runtime-caching recipe unchanged", () => { + const cfg = serviceWorkerConfig(stockManifest, stockRuntimeCaching); + expect(cfg.runtimeCaching).toBe(stockRuntimeCaching); + }); + + it("activates new workers immediately (skipWaiting + clientsClaim)", () => { + const cfg = serviceWorkerConfig([], []); + // Pinned because flipping these to false makes worker updates + // land only after every open tab closes — bad UX for an + // operator-tool that's typically open as one long-lived tab. + expect(cfg.skipWaiting).toBe(true); + expect(cfg.clientsClaim).toBe(true); + }); + + it("turns navigation preload on", () => { + const cfg = serviceWorkerConfig([], []); + // navigationPreload races the network fetch with the worker's + // boot. Without it the first navigation after a cold start + // measurably stalls. + expect(cfg.navigationPreload).toBe(true); + }); + + it("accepts an empty manifest (dev / no-op precache)", () => { + const cfg = serviceWorkerConfig([], []); + expect(cfg.precacheEntries).toEqual([]); + }); + + it("returns a flat object with no extra keys (config surface stays small)", () => { + const cfg = serviceWorkerConfig(stockManifest, stockRuntimeCaching); + // Defends against accidentally adding implicit fields the + // Serwist constructor would interpret silently. + expect(Object.keys(cfg).sort()).toEqual([ + "clientsClaim", + "navigationPreload", + "precacheEntries", + "runtimeCaching", + "skipWaiting", + ]); + }); +}); diff --git a/apps/web/src/pwa/config.ts b/apps/web/src/pwa/config.ts new file mode 100644 index 0000000..58576d7 --- /dev/null +++ b/apps/web/src/pwa/config.ts @@ -0,0 +1,57 @@ +/** + * Service-worker configuration for `@serwist/next`. Extracted from + * `sw.ts` so unit tests can pin the choices without booting up a + * service-worker scope. + * + * The choices we care about — and why they're the way they are: + * + * skipWaiting + clientsClaim + * A new worker takes over open tabs on the next navigation + * instead of waiting for every tab to close. Operators tend to + * live in one tab; faster updates win. + * + * navigationPreload + * Tells the browser it can race the network fetch for navigations + * alongside the worker boot, cutting first-paint when the worker + * is cold. + * + * runtimeCaching + * Serwist's stock recipe — HTML network-first with offline + * fallback, static assets cache-first, image / font caches with + * sensible TTLs. Easy to swap if we want bespoke strategies later. + * + * precacheEntries + * Whatever `__SW_MANIFEST` the build pipeline injected. We pass + * it through unchanged. + */ + +type PrecacheEntry = string | { url: string; revision: string | null }; + +/** + * The runtime-caching shape is generic on purpose. Serwist's own + * `RuntimeCaching` type pulls in WebWorker DOM lib refs that + * vitest's plain Node env doesn't ship; using a generic preserves + * the type at the call site (sw.ts passes Serwist's + * `RuntimeCaching[]` and gets it back unchanged) without forcing + * the lib import here. + */ +export interface ServiceWorkerConfig { + precacheEntries: PrecacheEntry[]; + skipWaiting: boolean; + clientsClaim: boolean; + navigationPreload: boolean; + runtimeCaching: R; +} + +export function serviceWorkerConfig( + precacheEntries: PrecacheEntry[], + runtimeCaching: R, +): ServiceWorkerConfig { + return { + precacheEntries, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching, + }; +} diff --git a/apps/web/src/pwa/sw.ts b/apps/web/src/pwa/sw.ts index 5d92319..59e6f21 100644 --- a/apps/web/src/pwa/sw.ts +++ b/apps/web/src/pwa/sw.ts @@ -1,6 +1,7 @@ /// import { defaultCache } from "@serwist/next/worker"; import { Serwist } from "serwist"; +import { serviceWorkerConfig } from "./config"; declare const self: ServiceWorkerGlobalScope & { __SW_MANIFEST: (string | { url: string; revision: string | null })[]; @@ -8,25 +9,11 @@ declare const self: ServiceWorkerGlobalScope & { /** * The service worker entry. `@serwist/next` builds this file into - * `public/sw.js` at production build time and the manifest is - * substituted into `__SW_MANIFEST` (URLs the worker should precache). + * `public/sw.js` at production build time and substitutes the + * precache manifest into `__SW_MANIFEST`. * - * - `skipWaiting` + `clientsClaim`: a new worker takes over the page - * on the next navigation rather than waiting for every tab to - * close. Operators tend to live in one tab; faster updates win. - * - `navigationPreload: true`: tells the browser it can race the - * network fetch for navigations alongside the worker boot, cutting - * first-paint when the worker is cold. - * - `runtimeCaching: defaultCache`: serwist's stock recipe — HTML - * network-first with offline fallback, static assets cache-first, - * image / font caches with sensible TTLs. + * The actual config choices live in `./config.ts` so a unit test + * can pin them without spinning up a service-worker scope. */ -const serwist = new Serwist({ - precacheEntries: self.__SW_MANIFEST, - skipWaiting: true, - clientsClaim: true, - navigationPreload: true, - runtimeCaching: defaultCache, -}); - +const serwist = new Serwist(serviceWorkerConfig(self.__SW_MANIFEST, defaultCache)); serwist.addEventListeners();