diff --git a/internal b/internal index 01d422f72..71ee3d7ca 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 01d422f720b16307d6b3ea35323e0d7260009752 +Subproject commit 71ee3d7ca8896677c2450c3026207daa7879f544 diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts index f687c10f0..42ad532a7 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts @@ -11,9 +11,16 @@ import { GENERIC_BODY, GENERIC_TITLE } from "../push/pushCore"; type Handler = (event: unknown) => void; +interface FakeClients { + claim: ReturnType; + matchAll: ReturnType; + openWindow: ReturnType; +} + interface FakeSelf { handlers: Map; showNotification: ReturnType; + clients: FakeClients; /** Order of the side effects the contract cares about. */ sequence: string[]; } @@ -24,6 +31,11 @@ function fakeSelf(): FakeSelf { const showNotification = vi.fn(async () => { sequence.push("showNotification"); }); + const clients: FakeClients = { + claim: vi.fn(async () => undefined), + matchAll: vi.fn(async () => []), + openWindow: vi.fn(async () => null), + }; const self = { addEventListener: (type: string, handler: Handler) => { handlers.set(type, handler); @@ -33,15 +45,11 @@ function fakeSelf(): FakeSelf { showNotification, pushManager: { getSubscription: vi.fn(async () => null) }, }, - clients: { - claim: vi.fn(async () => undefined), - matchAll: vi.fn(async () => []), - openWindow: vi.fn(async () => null), - }, + clients, location: { origin: "https://portal.example.test" }, }; vi.stubGlobal("self", self); - return { handlers, showNotification, sequence }; + return { handlers, showNotification, clients, sequence }; } /** An IDBFactory whose open() request never fires any handler. */ @@ -210,4 +218,87 @@ describe("push service worker glue (K25)", () => { await expect(event.waited).resolves.toBeDefined(); }); }); + + describe("push event carrying a declarative notification (Push API mutable, Safari ≥ 18.4)", () => { + const declared = { + title: "Enrollment finished", + body: "DESKTOP-4711 (…CDE9) finished enrollment.", + tag: "session-x", + data: { id: "srv-1", type: "session_watch", ts: "2026-10-08T12:40:15Z", severity: "success", facts: [], portalUrl: "https://portal.example.test/sessions?id=x", scope: "platform" }, + }; + + it("reads event.notification when event.data is null, shows the same content and persists it", async () => { + const env = await loadWorker(hangingIndexedDb); + const event = fakeEvent({ data: null, notification: declared }); + + env.handlers.get("push")!(event); + + expect(env.showNotification).toHaveBeenCalledTimes(1); + expect(env.showNotification.mock.calls[0][0]).toBe("Enrollment finished"); + expect(env.showNotification.mock.calls[0][1]).toMatchObject({ + body: "DESKTOP-4711 (…CDE9) finished enrollment.", + tag: "session-x", + data: { id: "srv-1", entry: expect.objectContaining({ id: "srv-1", type: "session_watch", severity: "success", portalUrl: "https://portal.example.test/sessions?id=x", scope: "platform" }) }, + }); + expect(env.sequence).toEqual(["showNotification", "indexedDB.open"]); + }); + + it("prefers event.notification over an unreadable event.data", async () => { + const env = await loadWorker(hangingIndexedDb); + const event = fakeEvent({ data: UNREADABLE_DATA, notification: declared }); + + env.handlers.get("push")!(event); + + expect(env.showNotification.mock.calls[0][0]).toBe("Enrollment finished"); + }); + }); + + describe("notificationclick on a notification the platform displayed itself", () => { + function clickEvent(notification: Record) { + const event = fakeEvent({}) as FakeEvent & { notification: Record }; + event.notification = { close: vi.fn(), ...notification }; + return event; + } + + const platformShown = { + title: "Enrollment failed", + body: "DESKTOP-4711 (…CDE9) failed enrollment.", + tag: "session-y", + data: { id: "srv-2", type: "session_watch", ts: "2026-10-08T12:45:00Z", severity: "error", facts: [{ name: "Device", value: "DESKTOP-4711" }], portalUrl: null, scope: "tenant" }, + }; + + it("rebuilds the history entry from the notification's data, writes it and opens the entry", async () => { + const env = await loadWorker(erroringIndexedDb); + const event = clickEvent(platformShown); + + env.handlers.get("notificationclick")!(event); + await expect(event.waited).resolves.toBeUndefined(); + + expect(env.indexedDbOpen).toHaveBeenCalledTimes(1); + expect(env.clients.openWindow).toHaveBeenCalledTimes(1); + expect(String(env.clients.openWindow.mock.calls[0][0])).toContain("srv-2"); + }); + + it("opens the page even when the history write never answers (bounded like the push path)", async () => { + const env = await loadWorker(hangingIndexedDb); + const event = clickEvent(platformShown); + + env.handlers.get("notificationclick")!(event); + await vi.advanceTimersByTimeAsync(5000); + await expect(event.waited).resolves.toBeUndefined(); + + expect(env.clients.openWindow).toHaveBeenCalledTimes(1); + }); + + it("keeps using the entry the push handler attached when there is one", async () => { + const env = await loadWorker(erroringIndexedDb); + const attached = { id: "srv-3", ts: "2026-10-08T12:50:00Z", title: "t", body: "b", type: "push_test", severity: "info", facts: [], portalUrl: null, navigate: null, tag: null, scope: "platform", generic: false }; + const event = clickEvent({ title: "t", body: "b", data: { id: "srv-3", entry: attached } }); + + env.handlers.get("notificationclick")!(event); + await expect(event.waited).resolves.toBeUndefined(); + + expect(String(env.clients.openWindow.mock.calls[0][0])).toContain("srv-3"); + }); + }); }); diff --git a/src/Web/autopilot-monitor-web/public/push/sw.js b/src/Web/autopilot-monitor-web/public/push/sw.js index 2e73fd230..8ebe646d3 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw.js +++ b/src/Web/autopilot-monitor-web/public/push/sw.js @@ -133,33 +133,48 @@ function handlePush(event, raw) { event.waitUntil(Promise.all([show(entry), persist(entry)])); } +// Declarative Web Push with "mutable": true (Push API draft; Safari / iOS ≥ 18.4, verified on a +// device 2026-10-08): the regular push event carries the proposed Notification in +// event.notification and event.data is null. The worker shows the same content itself, so the +// proposed notification is dropped and exactly one appears, and the history gets its entry at +// arrival instead of only after a tap. Every other browser hands the raw JSON as event.data. self.addEventListener("push", (event) => { - handlePush(event, safeParse(event.data)); + const declared = event.notification ?? null; + handlePush(event, declared ?? safeParse(event.data)); }); -// Safari / iOS ≥ 18.4: Declarative Web Push with "mutable": true wakes the worker with the -// proposed notification instead of a payload (event name verified at interop, K25). +// The earlier WebKit shape of the same mechanism: a separate pushnotification event with the +// proposed notification (kept until no supported Safari fires it). self.addEventListener("pushnotification", (event) => { const proposed = event.notification ?? event.proposedNotification ?? null; handlePush(event, proposed ?? safeParse(event.data ?? null)); }); +/** + * The history entry behind a clicked notification: the one the push handler attached, or — + * for a notification the platform displayed straight from the declarative JSON, without the + * worker running — rebuilt from the Notification itself (its data is the payload's data member). + * @param {Notification} notification + * @returns {import("./sw-core.js").HistoryEntry | null} + */ +function entryFromClicked(notification) { + const data = notification.data ?? {}; + if (data.entry && typeof data.entry === "object") return data.entry; + if (typeof data.id !== "string") return null; + return normalizePayload({ title: notification.title, body: notification.body, tag: notification.tag, data }, Date.now()); +} + self.addEventListener("notificationclick", (event) => { event.notification.close(); - const data = event.notification.data ?? {}; - const id = typeof data.id === "string" ? data.id : null; - const entry = data.entry && typeof data.entry === "object" ? data.entry : null; + const entry = entryFromClicked(event.notification); + const id = entry ? entry.id : null; const target = new URL("/push/" + (id ? entryFragment(id) : ""), self.location.origin).toString(); event.waitUntil( (async () => { - // The push handler may have lost the race against IndexedDB; the click writes the entry if missing. - if (entry) { - try { - await writeHistoryEntry(entry); - } catch { - // Display already happened; the history is best effort. - } - } + // The push handler may have lost the race against IndexedDB, or never ran: the click writes + // the entry if missing — bounded like every history write, so a stuck database cannot keep + // the tap from opening the page. + if (entry) await persist(entry); const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); for (const client of windows) { if (!new URL(client.url).pathname.startsWith("/push")) continue;