feat(site): suppress push notifications when agents page is visible (#22667)

This commit is contained in:
Danielle Maywood
2026-03-06 10:31:37 +00:00
committed by GitHub
parent ecf3dccbbc
commit 957fb556da
2 changed files with 221 additions and 5 deletions
+199
View File
@@ -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<Array<{ visibilityState: string; url: string }>>>();
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<string, (event: unknown) => 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<unknown> = Promise.resolve();
return {
data: {
json: () => payload,
},
waitUntil: (p: Promise<unknown>) => {
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,
});
});
});
+22 -5
View File
@@ -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,
});
}),
);
});