import "fake-indexeddb/auto" import "core-js/stable/structured-clone" import initWorker from "./worker" import Dexie, { EntityTable } from "dexie" function createSubscription(testToken = "HI") { return { toJSON: () => ({ subscription: testToken, }), } } const createSelf = () => ({ addEventListener: jest.fn(), skipWaiting: jest.fn(() => Promise.resolve()), registration: { pushManager: { subscribe: jest.fn(() => Promise.resolve(createSubscription())), getSubscription: jest.fn(() => Promise.resolve(createSubscription())), }, showNotification: jest.fn(), }, clients: { claim: jest.fn(() => Promise.resolve()), openWindow: jest.fn(() => Promise.resolve()), matchAll: jest.fn(() => Promise.resolve([])), }, }) as unknown as ServiceWorkerGlobalScope let originalWindow: Window & typeof globalThis describe("service worker", () => { let self: ServiceWorkerGlobalScope let controlledClients: Client[] let uncontrolledClients: Client[] beforeEach(() => { originalWindow = global.window global.window = undefined as any indexedDB = new IDBFactory() self = createSelf() controlledClients = [] uncontrolledClients = [] fetchMock.mockResponse((req) => { if ( req.url.replace(/https?:\/\/[a-zA-Z0-9\.]*/, "") === "/api/subscription/" && req.method === "POST" ) { return Promise.resolve({ body: JSON.stringify({ subscriptionId: 123 }), }) } else if ( req.url .replace(/https?:\/\/[a-zA-Z0-9\.]*/, "") .startsWith("/api/subscription/") && req.method === "PUT" ) { return Promise.resolve({ init: { status: 200 } }) } return Promise.resolve({ init: { status: 500 } }) }) }) afterEach(() => { jest.clearAllMocks() global.window = originalWindow }) test("displays push notifications", async () => { initWorker(self) const pushHandler = getHandler("push") const pushEvent = createPushEvent( JSON.stringify({ title: "Test title", body: "Test text", icon: "Test icon", badge: "Test badge", destination: "tackupnow.com/hi", }) ) pushHandler(pushEvent) await waitUntilCalls(pushEvent) expect(self.registration.showNotification).toHaveBeenCalledWith( "Test title", { body: "Test text", icon: "Test icon", badge: "Test badge", data: { destination: "tackupnow.com/hi" }, } ) }) describe("on notification click", () => { test("the notification is closed", async () => { initWorker(self) const notificationHandler = getHandler("notificationclick") const event = createNotificationEvent("tackupnow.com") notificationHandler(event) await waitUntilCalls(event) expect(event.notification.close).toHaveBeenCalled() }) test("opens tack up now if no clients match the destination", async () => { initWorker(self) const notificationHandler = getHandler("notificationclick") const event = createNotificationEvent("tackupnow.com") notificationHandler(event) await waitUntilCalls(event) expect(self.clients.openWindow).toHaveBeenCalledWith("tackupnow.com") }) test("focuses first matching client", async () => { addClient("https://tackupnow.com/place", "window", true) const matchingClient = addClient( "https://tackupnow.com/otherplace", "window", true ) addClient("https://tackupnow.com/yetanotherotherplace", "window", true) initWorker(self) const notificationHandler = getHandler("notificationclick") const event = createNotificationEvent("https://tackupnow.com/otherplace") notificationHandler(event) await waitUntilCalls(event) expect(matchingClient.focus).toHaveBeenCalled() }) test("focuses uncontrolled matching clients", async () => { addClient("https://derpatious.world", "window", false) addClient("https://tackupnow.com/1", "window", false) const matchingClient = addClient( "https://tackupnow.com/2", "window", false ) addClient("https://tackupnow.com/controlled", "window", true) initWorker(self) const notificationHandler = getHandler("notificationclick") const event = createNotificationEvent("https://tackupnow.com/2") notificationHandler(event) await waitUntilCalls(event) expect(matchingClient.focus).toHaveBeenCalled() }) }) test("claims on activate", async () => { initWorker(self) const activateHandler = getHandler("activate") const event = createEvent() activateHandler(event) await waitUntilCalls(event) expect(self.clients.claim).toHaveBeenCalled() }) test("skips waiting on install", async () => { initWorker(self) const installHandler = getHandler("install") const event = createEvent() installHandler(event) await waitUntilCalls(event) expect(self.skipWaiting).toHaveBeenCalled() }) describe("on subscription message", () => { test("puts push subscription if the subscription id is set", async () => { initWorker(self) await setSavedSubscription(5000) const messageHandler = getHandler("message") const messageEvent = createEvent({ data: { subscription: { subscription: "YO" }, type: "subscribed", }, }) messageHandler(messageEvent) await waitUntilCalls(messageEvent) expect(fetch).toHaveBeenCalledWith( `${process.env.BASE_URL}/api/subscription/5000`, { method: "PUT", body: JSON.stringify({ subscription: "YO" }), headers: { "Content-Type": "application/json", }, } ) }) test("posts to subscriptions when there is no user set", async () => { initWorker(self) const messageHandler = getHandler("message") const messageEvent = createEvent({ data: { subscription: { subscription: "YO" }, type: "subscribed", }, }) messageHandler(messageEvent) await waitUntilCalls(messageEvent) expect(fetch).toHaveBeenCalledWith( `${process.env.BASE_URL}/api/subscription/`, { method: "POST", body: JSON.stringify({ subscription: "YO" }), headers: { "Content-Type": "application/json", }, } ) }) test("subscription id is saved on first subscription change", async () => { initWorker(self) const messageHandler = getHandler("message") const messageEvent = createEvent({ data: { subscription: { subscription: "YO" }, type: "subscribed", }, }) messageHandler(messageEvent) await waitUntilCalls(messageEvent) expect(await getSavedSubscription()).toEqual({ id: 1, subscriptionId: 123, }) }) }) describe("on subscription change", () => { test("puts push subscription if the subscription id is set", async () => { initWorker(self) await setSavedSubscription(5000) const changeHandler = getHandler("pushsubscriptionchange") const changeEvent = createEvent() changeHandler(changeEvent) await waitUntilCalls(changeEvent) expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: expect.any(Uint8Array), }) expect(fetch).toHaveBeenCalledWith( `${process.env.BASE_URL}/api/subscription/5000`, { method: "PUT", body: JSON.stringify({ subscription: "HI" }), headers: { "Content-Type": "application/json", }, } ) }) test("posts to subscriptions when there is no user set", async () => { initWorker(self) const changeHandler = getHandler("pushsubscriptionchange") const changeEvent = createEvent() changeHandler(changeEvent) await waitUntilCalls(changeEvent) expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: expect.any(Uint8Array), }) expect(fetch).toHaveBeenCalledWith( `${process.env.BASE_URL}/api/subscription/`, { method: "POST", body: JSON.stringify({ subscription: "HI" }), headers: { "Content-Type": "application/json", }, } ) }) test("subscription id is saved on first subscription change", async () => { initWorker(self) const changeHandler = getHandler("pushsubscriptionchange") const changeEvent = createEvent() changeHandler(changeEvent) await waitUntilCalls(changeEvent) expect(await getSavedSubscription()).toEqual({ id: 1, subscriptionId: 123, }) }) }) function addClient(url: string, type: ClientTypes, controlled: boolean) { const matchAllMock = self.clients.matchAll as jest.Mock matchAllMock.mockImplementation( (args: { type: string; includeUncontrolled?: boolean }) => { const clientList = args.includeUncontrolled ? [...uncontrolledClients, ...controlledClients] : controlledClients return clientList.filter((client) => client.type === args.type) } ) const clientList = controlled ? controlledClients : uncontrolledClients const client: WindowClient = { url, type, focus: jest.fn(() => Promise.resolve(client)) as WindowClient["focus"], } as WindowClient clientList.push(client) return client } function getHandler(eventName: string) { expect(self.addEventListener).toHaveBeenCalledWith( eventName, expect.anything() ) const [, handler] = (self.addEventListener as jest.Mock).mock.calls.find( ([event]: [string]) => event === eventName ) return handler } }) async function getSavedSubscription() { const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & { subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id"> } database.version(1).stores({ subscriptions: "id++, subscriptionId&" }) return (await database.subscriptions.toArray())[0] } async function setSavedSubscription(subscriptionId: number) { const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & { subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id"> } database.version(1).stores({ subscriptions: "id++, subscriptionId&" }) await database.subscriptions.put({ id: 1, subscriptionId }) } function createPushEvent(data: string) { const pushFields = { data: { blob: () => new Blob([data], { type: "text/plain" }), json: () => JSON.parse(data), text: () => data, arrayBuffer: () => new TextEncoder().encode(data), }, } return createEvent(pushFields) } function createNotificationEvent(destination: string): NotificationEvent & { waitUntil: jest.Mock> } { return createEvent({ notification: { close: jest.fn(), data: { destination, }, }, }) as any as NotificationEvent & { waitUntil: jest.Mock> } } function createEvent(properties?: object) { return { waitUntil: jest.fn(), ...properties, } } function waitUntilCalls(event: { waitUntil: jest.Mock> }) { return Promise.all(event.waitUntil.mock.calls.map(([promise]) => promise)) }