Skip to content

Commit 66f07f9

Browse files
committed
frontend: add tests for versionChecker.ts
1 parent 6e335bc commit 66f07f9

File tree

2 files changed

+255
-5
lines changed

2 files changed

+255
-5
lines changed

frontend/src/test-setup.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -484,14 +484,22 @@ beforeAll(() => {
484484
// Setup any global test configuration here
485485

486486
// Mock localStorage
487+
const store = new Map<string, string>();
487488
const localStorageMock = {
488-
getItem: vi.fn(),
489-
setItem: vi.fn(),
490-
removeItem: vi.fn(),
491-
clear: vi.fn(),
492-
};
489+
getItem: vi.fn((key: string) => {
490+
const val = store.get(key);
491+
return typeof val === 'undefined' ? null : val;
492+
}),
493+
setItem: vi.fn((key: string, value: string) => { store.set(key, String(value)); }),
494+
removeItem: vi.fn((key: string) => { store.delete(key); }),
495+
clear: vi.fn(() => { store.clear(); }),
496+
key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null),
497+
} as unknown as Storage;
498+
Object.defineProperty(localStorageMock, 'length', { get: () => store.size });
493499
Object.defineProperty(window, 'localStorage', {
494500
value: localStorageMock,
501+
configurable: true,
502+
writable: true,
495503
});
496504

497505
// Mock window.location.reload
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { describe, it, beforeEach, vi, expect, afterEach } from 'vitest';
2+
import { forceReload, forceCheckForUpdates, startVersionChecking, stopVersionChecking } from './versionChecker';
3+
4+
// Helper to mock fetch responses sequence
5+
function mockFetchSequence(responses: Array<Response | Promise<Response>>) {
6+
let call = 0;
7+
globalThis.fetch = vi.fn(async () => {
8+
const r = responses[call++];
9+
return await r;
10+
}) as unknown as typeof fetch;
11+
}
12+
13+
function jsonResponse(obj: unknown, ok = true, headers: Record<string, string> = {}) {
14+
return new Response(JSON.stringify(obj), {
15+
status: ok ? 200 : 500,
16+
headers: { 'Content-Type': 'application/json', ...headers },
17+
});
18+
}
19+
20+
function okResponse(body = '', headers: Record<string, string> = {}) {
21+
return new Response(body, { status: 200, headers });
22+
}
23+
24+
describe('versionChecker', () => {
25+
const originalLocation = window.location;
26+
const originalConfirm = window.confirm;
27+
const originalAlert = window.alert;
28+
const originalNow = Date.now;
29+
30+
beforeEach(() => {
31+
Object.defineProperty(window, 'location', {
32+
value: { ...originalLocation, reload: vi.fn(), href: 'http://localhost/' },
33+
writable: true,
34+
configurable: true,
35+
});
36+
window.confirm = vi.fn();
37+
window.alert = vi.fn();
38+
});
39+
40+
afterEach(() => {
41+
window.confirm = originalConfirm;
42+
window.alert = originalAlert;
43+
// @ts-expect-error restore
44+
window.location = originalLocation;
45+
Date.now = originalNow;
46+
vi.restoreAllMocks();
47+
stopVersionChecking();
48+
});
49+
50+
it('forceCheckForUpdates reloads when a new version is detected and user confirms', async () => {
51+
// First call initializes current version via startVersionChecking
52+
mockFetchSequence([
53+
jsonResponse({ buildHash: 'v1' }), // init
54+
jsonResponse({ buildHash: 'v2' }), // check
55+
]);
56+
57+
startVersionChecking(60); // long interval; we won't wait for it
58+
59+
await Promise.resolve();
60+
61+
(window.confirm as unknown as ReturnType<typeof vi.fn>)
62+
.mockReturnValueOnce(true);
63+
64+
await forceCheckForUpdates();
65+
66+
expect(window.confirm).toHaveBeenCalled();
67+
expect(window.location.reload).toHaveBeenCalled();
68+
});
69+
70+
it('forceCheckForUpdates shows already_latest when no update', async () => {
71+
mockFetchSequence([
72+
jsonResponse({ buildHash: 'v1' }), // init
73+
jsonResponse({ buildHash: 'v1' }), // check
74+
]);
75+
76+
startVersionChecking(60);
77+
await Promise.resolve();
78+
79+
await forceCheckForUpdates();
80+
81+
expect(window.alert).toHaveBeenCalled();
82+
});
83+
84+
it('forceReload clears caches (when available), removes localStorage except allowlist, and updates href', async () => {
85+
const cachesDelete = vi.fn();
86+
const cachesKeys = vi.fn().mockResolvedValue(['a', 'b']);
87+
const cachesMock = { keys: cachesKeys, delete: cachesDelete };
88+
// @ts-expect-error assign
89+
globalThis.caches = cachesMock;
90+
// @ts-expect-error assign
91+
window.caches = cachesMock;
92+
93+
localStorage.setItem('debugMode', '1');
94+
localStorage.setItem('currentConnection', 'X');
95+
localStorage.setItem('loginSalt', 'Y');
96+
localStorage.setItem('removeMe', 'Z');
97+
98+
const swPost = vi.fn();
99+
// @ts-expect-error augment
100+
navigator.serviceWorker = { controller: { postMessage: swPost } } as unknown as ServiceWorkerContainer;
101+
102+
const originalHref = window.location.href;
103+
104+
forceReload();
105+
// wait a microtask for caches.keys().then(...) to run
106+
await Promise.resolve();
107+
108+
expect(cachesKeys).toHaveBeenCalled();
109+
expect(cachesDelete).toHaveBeenCalledTimes(2);
110+
expect(swPost).toHaveBeenCalledWith({ type: 'CLEAR_CACHE' });
111+
112+
expect(localStorage.getItem('debugMode')).toBe('1');
113+
expect(localStorage.getItem('loginSalt')).toBe('Y');
114+
expect(localStorage.getItem('currentConnection')).toBe('X');
115+
116+
expect(localStorage.getItem('removeMe')).toBeNull();
117+
118+
expect(window.location.href).not.toBe(originalHref);
119+
expect(window.location.href).toContain('_t=');
120+
});
121+
122+
it('startVersionChecking stops interval when user declines reload', async () => {
123+
mockFetchSequence([
124+
jsonResponse({ buildHash: 'v1' }), // init
125+
jsonResponse({ buildHash: 'v2' }), // first interval check
126+
]);
127+
128+
(window.confirm as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce(false);
129+
130+
startVersionChecking(0.001); // fast interval (~60ms) to trigger quickly
131+
132+
// Wait enough time for one interval tick
133+
await new Promise((r) => setTimeout(r, 100));
134+
135+
// If stopVersionChecking() was called inside, subsequent checks won't occur. We can’t easily assert internal interval
136+
// state, but at least ensure confirm was called once and no reload was triggered.
137+
expect(window.confirm).toHaveBeenCalledTimes(1);
138+
expect(window.location.reload).not.toHaveBeenCalled();
139+
});
140+
141+
it('falls back to index last-modified header when /version.json is not ok', async () => {
142+
const lastModified = 'Mon, 01 Jan 2024 00:00:00 GMT';
143+
mockFetchSequence([
144+
jsonResponse({}, false),
145+
okResponse('', { 'last-modified': lastModified }),
146+
jsonResponse({}, false),
147+
okResponse('', { 'last-modified': lastModified }),
148+
]);
149+
150+
startVersionChecking(60);
151+
await Promise.resolve();
152+
153+
await forceCheckForUpdates();
154+
155+
expect(window.alert).toHaveBeenCalled(); // already latest based on last-modified
156+
expect(window.location.reload).not.toHaveBeenCalled();
157+
});
158+
159+
it('falls back to Date.now() when last-modified header is missing', async () => {
160+
Date.now = vi.fn(() => 1234567890);
161+
mockFetchSequence([
162+
// init path -> /version.json fails, index ok without header
163+
jsonResponse({}, false),
164+
okResponse(''),
165+
// force check path -> same again
166+
jsonResponse({}, false),
167+
okResponse(''),
168+
]);
169+
170+
startVersionChecking(60);
171+
await Promise.resolve();
172+
173+
await forceCheckForUpdates();
174+
175+
expect(window.alert).toHaveBeenCalled();
176+
expect(window.location.reload).not.toHaveBeenCalled();
177+
// ensure Date.now was used (may be called multiple times across init + check)
178+
expect(Date.now).toHaveBeenCalled();
179+
});
180+
181+
it('handles fetch errors gracefully and treats as no update', async () => {
182+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation((..._args: unknown[]) => {
183+
// swallow logs during test
184+
});
185+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down')) as unknown as typeof fetch;
186+
187+
startVersionChecking(60);
188+
await Promise.resolve();
189+
190+
await forceCheckForUpdates();
191+
192+
expect(warnSpy).toHaveBeenCalled();
193+
expect(window.alert).toHaveBeenCalled();
194+
});
195+
196+
it('clears previous interval when startVersionChecking is called again', async () => {
197+
const clearSpy = vi.spyOn(globalThis, 'clearInterval');
198+
mockFetchSequence([
199+
jsonResponse({ buildHash: 'v1' }), // first start init
200+
jsonResponse({ buildHash: 'v1' }), // second start init
201+
]);
202+
203+
startVersionChecking(60);
204+
await Promise.resolve();
205+
startVersionChecking(60);
206+
await Promise.resolve();
207+
208+
expect(clearSpy).toHaveBeenCalledTimes(1);
209+
});
210+
211+
it('startVersionChecking reloads when user confirms on new version', async () => {
212+
mockFetchSequence([
213+
jsonResponse({ buildHash: 'v1' }), // init
214+
jsonResponse({ buildHash: 'v2' }), // interval check
215+
]);
216+
217+
(window.confirm as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce(true);
218+
219+
startVersionChecking(0.001);
220+
221+
await new Promise((r) => setTimeout(r, 100));
222+
223+
expect(window.location.reload).toHaveBeenCalled();
224+
});
225+
226+
it('stopVersionChecking clears active interval and is idempotent', async () => {
227+
const clearSpy = vi.spyOn(globalThis, 'clearInterval');
228+
mockFetchSequence([
229+
jsonResponse({ buildHash: 'v1' }),
230+
]);
231+
232+
startVersionChecking(60);
233+
await Promise.resolve();
234+
235+
stopVersionChecking();
236+
expect(clearSpy).toHaveBeenCalledTimes(1);
237+
238+
// second call should not throw and not call clearInterval again
239+
stopVersionChecking();
240+
expect(clearSpy).toHaveBeenCalledTimes(1);
241+
});
242+
});

0 commit comments

Comments
 (0)