From 957fb556da3a4462a32a8c7ce8fb7ca357e93279 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Fri, 6 Mar 2026 10:31:37 +0000 Subject: [PATCH] feat(site): suppress push notifications when agents page is visible (#22667) --- site/src/serviceWorker.test.ts | 199 +++++++++++++++++++++++++++++++++ site/src/serviceWorker.ts | 27 ++++- 2 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 site/src/serviceWorker.test.ts diff --git a/site/src/serviceWorker.test.ts b/site/src/serviceWorker.test.ts new file mode 100644 index 0000000000..dedd2bef1a --- /dev/null +++ b/site/src/serviceWorker.test.ts @@ -0,0 +1,199 @@ +import type { WebpushMessage } from "api/typesGenerated"; + +// We need to mock the ServiceWorkerGlobalScope before importing the +// module, since serviceWorker.ts registers event listeners at the +// top level during module evaluation. + +const mockShowNotification = vi.fn(() => Promise.resolve()); +const mockRegistration = { showNotification: mockShowNotification }; +const mockMatchAll = + vi.fn<() => Promise>>(); +const mockClients = { + matchAll: mockMatchAll, + claim: vi.fn(() => Promise.resolve()), + openWindow: vi.fn(() => Promise.resolve(null)), +}; + +// Collect handlers registered via addEventListener so we can +// invoke them directly in tests. +const handlers: Record void> = {}; +const mockSelf = { + addEventListener: vi.fn( + (event: string, handler: (event: unknown) => void) => { + handlers[event] = handler; + }, + ), + skipWaiting: vi.fn(), + clients: mockClients, + registration: mockRegistration, +}; + +// Assign our mock to the global `self` that serviceWorker.ts +// references via `declare const self`. +Object.assign(globalThis, { self: mockSelf }); + +// Helper to build a minimal PushEvent-like object that carries +// a JSON payload and exposes waitUntil so we can await the +// handler's async work. +function makePushEvent(payload: WebpushMessage) { + let waitUntilPromise: Promise = Promise.resolve(); + return { + data: { + json: () => payload, + }, + waitUntil: (p: Promise) => { + waitUntilPromise = p; + }, + // Expose the promise so tests can await it. + get _waitUntilPromise() { + return waitUntilPromise; + }, + }; +} + +const testPayload: WebpushMessage = { + title: "Test Notification", + body: "Something happened", + icon: "/icon.png", + actions: [], + data: { url: "/agents/abc" }, +}; + +// Import the service worker module. This executes the top-level +// addEventListener calls which populate our `handlers` map. +beforeAll(async () => { + await import("./serviceWorker"); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("serviceWorker push handler", () => { + it("shows notification when no visible agents window exists", async () => { + mockMatchAll.mockResolvedValue([]); + + const event = makePushEvent(testPayload); + handlers.push(event); + await event._waitUntilPromise; + + expect(mockShowNotification).toHaveBeenCalledWith(testPayload.title, { + body: testPayload.body, + icon: testPayload.icon, + data: testPayload.data, + }); + }); + + it("suppresses notification when viewing the specific chat", async () => { + mockMatchAll.mockResolvedValue([ + { visibilityState: "visible", url: "https://example.com/agents/abc" }, + ]); + + const event = makePushEvent(testPayload); + handlers.push(event); + await event._waitUntilPromise; + + expect(mockShowNotification).not.toHaveBeenCalled(); + }); + + it("shows notification when viewing a different chat", async () => { + mockMatchAll.mockResolvedValue([ + { + visibilityState: "visible", + url: "https://example.com/agents/other-chat-id", + }, + ]); + + const event = makePushEvent(testPayload); + handlers.push(event); + await event._waitUntilPromise; + + expect(mockShowNotification).toHaveBeenCalledWith(testPayload.title, { + body: testPayload.body, + icon: testPayload.icon, + data: testPayload.data, + }); + }); + + it("shows notification when payload has no data url", async () => { + mockMatchAll.mockResolvedValue([ + { visibilityState: "visible", url: "https://example.com/agents/abc" }, + ]); + + const payload: WebpushMessage = { + title: "No Data", + body: "test", + icon: "/icon.png", + actions: [], + }; + const event = makePushEvent(payload); + handlers.push(event); + await event._waitUntilPromise; + + expect(mockShowNotification).toHaveBeenCalledWith("No Data", { + body: "test", + icon: "/icon.png", + data: undefined, + }); + }); + + it("shows notification when specific chat page exists but is hidden", async () => { + mockMatchAll.mockResolvedValue([ + { visibilityState: "hidden", url: "https://example.com/agents/abc" }, + ]); + + const event = makePushEvent(testPayload); + handlers.push(event); + await event._waitUntilPromise; + + expect(mockShowNotification).toHaveBeenCalledWith(testPayload.title, { + body: testPayload.body, + icon: testPayload.icon, + data: testPayload.data, + }); + }); + + it("shows notification when visible window is not on agents page", async () => { + mockMatchAll.mockResolvedValue([ + { visibilityState: "visible", url: "https://example.com/settings" }, + ]); + + const event = makePushEvent(testPayload); + handlers.push(event); + await event._waitUntilPromise; + + expect(mockShowNotification).toHaveBeenCalledWith(testPayload.title, { + body: testPayload.body, + icon: testPayload.icon, + data: testPayload.data, + }); + }); + + it("does not show notification when push event has no data", () => { + const event = { data: null }; + // Should return early without calling waitUntil, so no + // error and no notification. + handlers.push(event); + expect(mockShowNotification).not.toHaveBeenCalled(); + }); + + it("uses default icon when payload icon is empty", async () => { + mockMatchAll.mockResolvedValue([]); + + const payload: WebpushMessage = { + title: "No Icon", + body: "", + icon: "", + actions: [], + }; + const event = makePushEvent(payload); + handlers.push(event); + await event._waitUntilPromise; + + expect(mockShowNotification).toHaveBeenCalledWith("No Icon", { + body: "", + icon: "/favicon.ico", + data: undefined, + }); + }); +}); diff --git a/site/src/serviceWorker.ts b/site/src/serviceWorker.ts index c8200ff2a0..4c53bf2bed 100644 --- a/site/src/serviceWorker.ts +++ b/site/src/serviceWorker.ts @@ -26,11 +26,28 @@ self.addEventListener("push", (event) => { } event.waitUntil( - self.registration.showNotification(payload.title, { - body: payload.body || "", - icon: payload.icon || "/favicon.ico", - data: payload.data, - }), + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clientList) => { + // Only suppress if the user is actively viewing the + // specific chat that triggered this notification. + const chatURL = payload.data?.url; + if (chatURL) { + const isVisible = clientList.some( + (client) => + client.visibilityState === "visible" && + client.url.includes(chatURL), + ); + if (isVisible) { + return; + } + } + return self.registration.showNotification(payload.title, { + body: payload.body || "", + icon: payload.icon || "/favicon.ico", + data: payload.data, + }); + }), ); });