Skip to content
File

Blob: tests/client/service-worker.test.ts

typescript127 lines
1import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2 
3type RouteMatchOptions = {
4 url: URL;
5 request: {
6 method: string;
7 mode: string;
8 };
9 sameOrigin: boolean;
10};
11 
12const serwistMock = vi.hoisted(() => {
13 class MockStrategy {
14 constructor(readonly options?: Record<string, unknown>) {}
15 }
16 
17 class MockNetworkOnly extends MockStrategy {}
18 class MockNetworkFirst extends MockStrategy {}
19 class MockPrecacheFallbackPlugin extends MockStrategy {}
20 class MockExpirationPlugin extends MockStrategy {}
21 class MockCacheableResponsePlugin extends MockStrategy {}
22 
23 class MockSerwist {
24 readonly captures: Array<{
25 match: (options: RouteMatchOptions) => boolean;
26 handler: MockStrategy;
27 }> = [];
28 
29 constructor(readonly options: Record<string, unknown>) {
30 serwistMock.instances.push(this);
31 }
32 
33 registerCapture(match: (options: RouteMatchOptions) => boolean, handler: MockStrategy): void {
34 this.captures.push({ match, handler });
35 }
36 
37 addEventListeners(): void {}
38 }
39 
40 const serwistMock = {
41 instances: [] as MockSerwist[],
42 Serwist: MockSerwist,
43 NetworkOnly: MockNetworkOnly,
44 NetworkFirst: MockNetworkFirst,
45 PrecacheFallbackPlugin: MockPrecacheFallbackPlugin,
46 ExpirationPlugin: MockExpirationPlugin,
47 CacheableResponsePlugin: MockCacheableResponsePlugin,
48 };
49 
50 return serwistMock;
51});
52 
53vi.mock("serwist", () => ({
54 CacheableResponsePlugin: serwistMock.CacheableResponsePlugin,
55 ExpirationPlugin: serwistMock.ExpirationPlugin,
56 NetworkFirst: serwistMock.NetworkFirst,
57 NetworkOnly: serwistMock.NetworkOnly,
58 PrecacheFallbackPlugin: serwistMock.PrecacheFallbackPlugin,
59 Serwist: serwistMock.Serwist,
60}));
61 
62const APP_ORIGIN = "https://bland.tools";
63 
64function handlerFor(
65 pathname: string,
66 options: { method?: string; mode?: string; origin?: string } = {},
67): InstanceType<typeof serwistMock.NetworkOnly> | InstanceType<typeof serwistMock.NetworkFirst> | undefined {
68 const origin = options.origin ?? APP_ORIGIN;
69 const url = new URL(pathname, origin);
70 const routeOptions: RouteMatchOptions = {
71 url,
72 request: {
73 method: options.method ?? "GET",
74 mode: options.mode ?? "navigate",
75 },
76 sameOrigin: url.origin === APP_ORIGIN,
77 };
78 
79 return serwistMock.instances[0].captures.find(({ match }) => match(routeOptions))?.handler;
80}
81 
82beforeAll(async () => {
83 vi.stubGlobal("self", { __SW_MANIFEST: [] });
84 await import("@/client/service-worker");
85});
86 
87afterAll(() => {
88 vi.unstubAllGlobals();
89});
90 
91describe("service worker navigation routing", () => {
92 it("keeps navigation preload enabled", () => {
93 expect(serwistMock.instances).toHaveLength(1);
94 expect(serwistMock.instances[0].options).toMatchObject({ navigationPreload: true });
95 });
96 
97 it("routes API and party navigations through bare NetworkOnly", () => {
98 for (const pathname of ["/api/v1/oidc/start", "/api/v1/oidc/callback", "/parties/doc-sync"]) {
99 const handler = handlerFor(pathname);
100 expect(handler).toBeInstanceOf(serwistMock.NetworkOnly);
101 expect(handler?.options).toBeUndefined();
102 }
103 });
104 
105 it("keeps cacheable upload GETs on NetworkFirst", () => {
106 expect(handlerFor("/uploads/01ABC")).toBeInstanceOf(serwistMock.NetworkFirst);
107 expect(handlerFor("/uploads/01ABC", { mode: "no-cors" })).toBeInstanceOf(serwistMock.NetworkFirst);
108 });
109 
110 it("routes non-cacheable upload navigations through bare NetworkOnly", () => {
111 const handler = handlerFor("/uploads/01ABC/data");
112 expect(handler).toBeInstanceOf(serwistMock.NetworkOnly);
113 expect(handler?.options).toBeUndefined();
114 });
115 
116 it("retains the precache fallback only for app navigations", () => {
117 const handler = handlerFor("/workspace/page");
118 expect(handler).toBeInstanceOf(serwistMock.NetworkOnly);
119 expect(handler?.options?.plugins).toEqual([expect.any(serwistMock.PrecacheFallbackPlugin)]);
120 });
121 
122 it("does not capture non-navigation API requests or cross-origin navigations", () => {
123 expect(handlerFor("/api/v1/oidc/callback", { mode: "cors" })).toBeUndefined();
124 expect(handlerFor("/api/v1/oidc/callback", { origin: "https://example.com" })).toBeUndefined();
125 });
126});