Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal
103 changes: 97 additions & 6 deletions src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@ import { GENERIC_BODY, GENERIC_TITLE } from "../push/pushCore";

type Handler = (event: unknown) => void;

interface FakeClients {
claim: ReturnType<typeof vi.fn>;
matchAll: ReturnType<typeof vi.fn>;
openWindow: ReturnType<typeof vi.fn>;
}

interface FakeSelf {
handlers: Map<string, Handler>;
showNotification: ReturnType<typeof vi.fn>;
clients: FakeClients;
/** Order of the side effects the contract cares about. */
sequence: string[];
}
Expand All @@ -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);
Expand All @@ -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. */
Expand Down Expand Up @@ -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<string, unknown>) {
const event = fakeEvent({}) as FakeEvent & { notification: Record<string, unknown> };
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");
});
});
});
43 changes: 29 additions & 14 deletions src/Web/autopilot-monitor-web/public/push/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading