Skip to content
File

Blob: tests/client/lib/pwa.dom.test.ts

typescript219 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2 
3let clearPwaRuntimeCaches: typeof import("@/client/lib/pwa").clearPwaRuntimeCaches;
4let registerServiceWorker: typeof import("@/client/lib/pwa").registerServiceWorker;
5let usePwaUpdate: typeof import("@/client/lib/pwa").usePwaUpdate;
6 
7const serwistMock = vi.hoisted(() => {
8 class MockSerwist {
9 scriptURL: string;
10 options: RegistrationOptions;
11 listeners = new Map<string, Array<() => void>>();
12 register = vi.fn(async () => undefined);
13 update = vi.fn(async () => undefined);
14 messageSkipWaiting = vi.fn();
15 
16 constructor(scriptURL: string, options: RegistrationOptions) {
17 this.scriptURL = scriptURL;
18 this.options = options;
19 serwistMock.instances.push(this);
20 }
21 
22 addEventListener(type: string, listener: () => void) {
23 const listeners = this.listeners.get(type) ?? [];
24 listeners.push(listener);
25 this.listeners.set(type, listeners);
26 }
27 
28 emit(type: string) {
29 for (const listener of this.listeners.get(type) ?? []) {
30 listener();
31 }
32 }
33 }
34 
35 const serwistMock = {
36 instances: [] as MockSerwist[],
37 Serwist: MockSerwist,
38 };
39 
40 return serwistMock;
41});
42 
43const reloadPwaMock = vi.hoisted(() => vi.fn());
44 
45vi.mock("@serwist/window", () => ({ Serwist: serwistMock.Serwist }));
46vi.mock("react", () => ({
47 useSyncExternalStore: (_subscribe: () => () => void, getSnapshot: () => unknown) => getSnapshot(),
48}));
49vi.mock("@/client/lib/pwa-reload", () => ({ reloadPwa: reloadPwaMock }));
50 
51let originalCachesDescriptor: PropertyDescriptor | undefined;
52let originalServiceWorkerDescriptor: PropertyDescriptor | undefined;
53let originalVisibilityDescriptor: PropertyDescriptor | undefined;
54 
55let cachesDeleted: string[];
56 
57function installCachesStub(options: { throwOnDelete?: boolean } = {}) {
58 cachesDeleted = [];
59 const stub = {
60 delete: async (name: string) => {
61 if (options.throwOnDelete) throw new Error("quota exceeded");
62 cachesDeleted.push(name);
63 return true;
64 },
65 };
66 Object.defineProperty(globalThis, "caches", { value: stub, writable: true, configurable: true });
67}
68 
69function uninstallCaches() {
70 Object.defineProperty(globalThis, "caches", { value: undefined, writable: true, configurable: true });
71}
72 
73function installServiceWorker(): void {
74 Object.defineProperty(navigator, "serviceWorker", {
75 value: { register: vi.fn(async () => ({})) },
76 writable: true,
77 configurable: true,
78 });
79}
80 
81function uninstallServiceWorker(): void {
82 // Force the `"serviceWorker" in navigator` check to be false by deleting
83 // the slot on the navigator instance.
84 delete (navigator as { serviceWorker?: unknown }).serviceWorker;
85}
86 
87function setVisibilityState(state: DocumentVisibilityState): void {
88 Object.defineProperty(document, "visibilityState", {
89 value: state,
90 writable: true,
91 configurable: true,
92 });
93}
94 
95beforeEach(async () => {
96 originalCachesDescriptor = Object.getOwnPropertyDescriptor(globalThis, "caches");
97 originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
98 originalVisibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
99 
100 reloadPwaMock.mockReset();
101 vi.resetModules();
102 vi.unstubAllEnvs();
103 serwistMock.instances.length = 0;
104 const mod = await import("@/client/lib/pwa");
105 clearPwaRuntimeCaches = mod.clearPwaRuntimeCaches;
106 registerServiceWorker = mod.registerServiceWorker;
107 usePwaUpdate = mod.usePwaUpdate;
108});
109 
110afterEach(() => {
111 if (originalCachesDescriptor) {
112 Object.defineProperty(globalThis, "caches", originalCachesDescriptor);
113 } else {
114 delete (globalThis as { caches?: unknown }).caches;
115 }
116 if (originalServiceWorkerDescriptor) {
117 Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
118 } else {
119 delete (navigator as { serviceWorker?: unknown }).serviceWorker;
120 }
121 if (originalVisibilityDescriptor) {
122 Object.defineProperty(document, "visibilityState", originalVisibilityDescriptor);
123 } else {
124 delete (document as { visibilityState?: unknown }).visibilityState;
125 }
126 vi.unstubAllEnvs();
127 vi.restoreAllMocks();
128});
129 
130describe("clearPwaRuntimeCaches", () => {
131 it("deletes only the bland-uploads-v1 runtime cache", async () => {
132 installCachesStub();
133 await clearPwaRuntimeCaches();
134 expect(cachesDeleted).toEqual(["bland-uploads-v1"]);
135 });
136 
137 it("is a no-op when Cache Storage is unavailable", async () => {
138 uninstallCaches();
139 await expect(clearPwaRuntimeCaches()).resolves.toBeUndefined();
140 });
141 
142 it("does not throw when cache.delete rejects", async () => {
143 installCachesStub({ throwOnDelete: true });
144 await expect(clearPwaRuntimeCaches()).resolves.toBeUndefined();
145 });
146});
147 
148describe("registerServiceWorker", () => {
149 it("is a no-op when navigator.serviceWorker is unavailable", () => {
150 uninstallServiceWorker();
151 expect(() => registerServiceWorker()).not.toThrow();
152 expect(serwistMock.instances).toHaveLength(0);
153 });
154 
155 it("is a no-op outside production", () => {
156 vi.stubEnv("PROD", false);
157 installServiceWorker();
158 registerServiceWorker();
159 expect(serwistMock.instances).toHaveLength(0);
160 });
161 
162 it("registers the production service worker through Serwist", () => {
163 vi.stubEnv("PROD", true);
164 installServiceWorker();
165 registerServiceWorker();
166 
167 expect(serwistMock.instances).toHaveLength(1);
168 expect(serwistMock.instances[0].scriptURL).toBe("/sw.js");
169 expect(serwistMock.instances[0].options).toEqual({ scope: "/", type: "classic" });
170 expect(serwistMock.instances[0].register).toHaveBeenCalledTimes(1);
171 });
172 
173 it("exposes a waiting update prompt and applies it on request", () => {
174 vi.stubEnv("PROD", true);
175 installServiceWorker();
176 
177 registerServiceWorker();
178 const instance = serwistMock.instances[0];
179 
180 expect(usePwaUpdate()).toEqual({ waiting: false, apply: null });
181 
182 instance.emit("waiting");
183 const update = usePwaUpdate();
184 expect(update.waiting).toBe(true);
185 expect(update.apply).toEqual(expect.any(Function));
186 
187 update.apply?.();
188 expect(instance.messageSkipWaiting).toHaveBeenCalledTimes(1);
189 expect(reloadPwaMock).not.toHaveBeenCalled();
190 
191 instance.emit("controlling");
192 expect(reloadPwaMock).toHaveBeenCalledTimes(1);
193 });
194 
195 it("rechecks for updates when the tab becomes visible", () => {
196 vi.stubEnv("PROD", true);
197 installServiceWorker();
198 
199 registerServiceWorker();
200 const instance = serwistMock.instances[0];
201 
202 setVisibilityState("visible");
203 document.dispatchEvent(new Event("visibilitychange"));
204 expect(instance.update).toHaveBeenCalledTimes(1);
205 });
206 
207 it("does not recheck for updates while the tab is hidden", () => {
208 vi.stubEnv("PROD", true);
209 installServiceWorker();
210 
211 registerServiceWorker();
212 const instance = serwistMock.instances[0];
213 
214 setVisibilityState("hidden");
215 document.dispatchEvent(new Event("visibilitychange"));
216 expect(instance.update).not.toHaveBeenCalled();
217 });
218});