-
Notifications
You must be signed in to change notification settings - Fork 0
Add version check to UI #691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,268 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| const CURRENT = "abc123"; | ||
| const NEWER = "xyz789"; | ||
|
|
||
| // Drain the async microtask queue (fetch -> json -> callback chain needs a few ticks) | ||
| const flushPromises = async () => { | ||
| await Promise.resolve(); | ||
| await Promise.resolve(); | ||
| await Promise.resolve(); | ||
| }; | ||
|
|
||
| function makeResponse( | ||
| version: string | null, | ||
| opts: { ok?: boolean; contentType?: string } = {}, | ||
| ) { | ||
| const { ok = true, contentType = "application/json" } = opts; | ||
| return Promise.resolve({ | ||
| ok, | ||
| headers: { get: () => contentType }, | ||
| json: () => Promise.resolve(version !== null ? { version } : {}), | ||
| } as unknown as Response); | ||
| } | ||
|
|
||
| describe("versionCheck", () => { | ||
| let startVersionPolling: (onUpdate?: () => void) => void; | ||
| let forceRefresh: () => void; | ||
| let reloadSpy: ReturnType<typeof vi.fn>; | ||
| const registeredListeners: Array< | ||
| [string, EventListenerOrEventListenerObject] | ||
| > = []; | ||
|
|
||
| beforeEach(async () => { | ||
| vi.resetModules(); | ||
| vi.useFakeTimers(); | ||
| vi.stubEnv("VITE_BUILD_HASH", CURRENT); | ||
| vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
|
|
||
| // Track visibilitychange listeners so we can remove them between tests | ||
| const origAdd = document.addEventListener.bind(document); | ||
| vi.spyOn(document, "addEventListener").mockImplementation( | ||
| (event, handler, ...args) => { | ||
| if (event === "visibilitychange") { | ||
| registeredListeners.push([event, handler]); | ||
| } | ||
| return origAdd(event, handler, ...(args as [])); | ||
| }, | ||
| ); | ||
|
|
||
| reloadSpy = vi.fn(); | ||
| Object.defineProperty(window, "location", { | ||
|
Check warning on line 51 in src/ui/versionCheck.test.ts
|
||
| configurable: true, | ||
| value: { reload: reloadSpy }, | ||
| }); | ||
| Object.defineProperty(window, "caches", { | ||
|
Check warning on line 55 in src/ui/versionCheck.test.ts
|
||
| configurable: true, | ||
| value: { keys: vi.fn().mockResolvedValue([]), delete: vi.fn() }, | ||
| }); | ||
|
|
||
| const mod = await import("./versionCheck"); | ||
| startVersionPolling = mod.startVersionPolling; | ||
| forceRefresh = mod.forceRefresh; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| // Remove any listeners registered during the test to prevent cross-test bleed | ||
| registeredListeners.forEach(([event, handler]) => | ||
| document.removeEventListener(event, handler), | ||
| ); | ||
| registeredListeners.length = 0; | ||
| vi.useRealTimers(); | ||
| vi.unstubAllEnvs(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe("startVersionPolling", () => { | ||
| it("runs an immediate check on startup", async () => { | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); | ||
|
Check warning on line 78 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(); | ||
| await flushPromises(); | ||
| expect(global.fetch).toHaveBeenCalledOnce(); | ||
|
Check warning on line 81 in src/ui/versionCheck.test.ts
|
||
| }); | ||
|
|
||
| it("rechecks every 5 minutes after the initial check", async () => { | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); | ||
|
Check warning on line 85 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(); | ||
| await flushPromises(); | ||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
|
Check warning on line 88 in src/ui/versionCheck.test.ts
|
||
|
|
||
| await vi.advanceTimersByTimeAsync(5 * 60 * 1000); | ||
| expect(global.fetch).toHaveBeenCalledTimes(2); | ||
|
Check warning on line 91 in src/ui/versionCheck.test.ts
|
||
|
|
||
| await vi.advanceTimersByTimeAsync(5 * 60 * 1000); | ||
| expect(global.fetch).toHaveBeenCalledTimes(3); | ||
|
Check warning on line 94 in src/ui/versionCheck.test.ts
|
||
| }); | ||
|
|
||
| it("registers a visibilitychange listener", () => { | ||
| global.fetch = vi.fn().mockReturnValue(new Promise(() => {})); | ||
|
Check warning on line 98 in src/ui/versionCheck.test.ts
|
||
| const addSpy = vi.spyOn(document, "addEventListener"); | ||
| startVersionPolling(); | ||
| expect(addSpy).toHaveBeenCalledWith( | ||
| "visibilitychange", | ||
| expect.any(Function), | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("checkForUpdate", () => { | ||
| it("does nothing when version matches current", async () => { | ||
| const onUpdate = vi.fn(); | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); | ||
|
Check warning on line 111 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(onUpdate); | ||
| await flushPromises(); | ||
| expect(onUpdate).not.toHaveBeenCalled(); | ||
| expect(reloadSpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("calls onUpdate when a newer version is detected", async () => { | ||
| const onUpdate = vi.fn(); | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER)); | ||
|
Check warning on line 120 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(onUpdate); | ||
| await flushPromises(); | ||
| expect(onUpdate).toHaveBeenCalledOnce(); | ||
| expect(reloadSpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("calls forceRefresh when version differs and no callback is set", async () => { | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER)); | ||
|
Check warning on line 128 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(); | ||
| await flushPromises(); | ||
| expect(reloadSpy).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| it("does nothing when CURRENT_VERSION is undefined (unbuilt dev asset)", async () => { | ||
| vi.unstubAllEnvs(); | ||
| vi.resetModules(); | ||
| const mod = await import("./versionCheck"); | ||
| const onUpdate = vi.fn(); | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER)); | ||
|
Check warning on line 139 in src/ui/versionCheck.test.ts
|
||
| mod.startVersionPolling(onUpdate); | ||
| await flushPromises(); | ||
| expect(onUpdate).not.toHaveBeenCalled(); | ||
| expect(reloadSpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("stops polling after an update is detected", async () => { | ||
| const onUpdate = vi.fn(); | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(NEWER)); | ||
|
Check warning on line 148 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(onUpdate); | ||
| await flushPromises(); | ||
| expect(onUpdate).toHaveBeenCalledTimes(1); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(5 * 60 * 1000); | ||
| expect(onUpdate).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("stops polling when response is not JSON", async () => { | ||
| global.fetch = vi | ||
|
Check warning on line 158 in src/ui/versionCheck.test.ts
|
||
| .fn() | ||
| .mockReturnValue(makeResponse(null, { contentType: "text/html" })); | ||
| startVersionPolling(); | ||
| await flushPromises(); | ||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
|
Check warning on line 163 in src/ui/versionCheck.test.ts
|
||
|
|
||
| await vi.advanceTimersByTimeAsync(5 * 60 * 1000); | ||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
|
Check warning on line 166 in src/ui/versionCheck.test.ts
|
||
| }); | ||
|
|
||
| it("does nothing when response is not ok", async () => { | ||
| const onUpdate = vi.fn(); | ||
| global.fetch = vi | ||
|
Check warning on line 171 in src/ui/versionCheck.test.ts
|
||
| .fn() | ||
| .mockReturnValue(makeResponse(NEWER, { ok: false })); | ||
| startVersionPolling(onUpdate); | ||
| await flushPromises(); | ||
| expect(onUpdate).not.toHaveBeenCalled(); | ||
| expect(reloadSpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("keeps polling after fewer than 10 fetch failures", async () => { | ||
| global.fetch = vi.fn().mockRejectedValue(new Error("network error")); | ||
|
Check warning on line 181 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(); | ||
|
|
||
| for (let i = 0; i < 5; i++) { | ||
| await vi.advanceTimersByTimeAsync(5 * 60 * 1000); | ||
| } | ||
|
|
||
| // 1 immediate + 5 interval = 6 total calls, polling still active | ||
| expect(global.fetch).toHaveBeenCalledTimes(6); | ||
|
Check warning on line 189 in src/ui/versionCheck.test.ts
|
||
| }); | ||
|
|
||
| it("stops polling after 10 consecutive failures", async () => { | ||
| global.fetch = vi.fn().mockRejectedValue(new Error("network error")); | ||
|
Check warning on line 193 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(); | ||
|
|
||
| // 1 immediate + 10 interval ticks exhausts the counter | ||
| for (let i = 0; i < 11; i++) { | ||
| await vi.advanceTimersByTimeAsync(5 * 60 * 1000); | ||
| } | ||
|
|
||
| const callCount = (global.fetch as ReturnType<typeof vi.fn>).mock.calls | ||
|
Check warning on line 201 in src/ui/versionCheck.test.ts
|
||
| .length; | ||
| await vi.advanceTimersByTimeAsync(5 * 60 * 1000); | ||
| expect((global.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe( | ||
|
Check warning on line 204 in src/ui/versionCheck.test.ts
|
||
| callCount, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("visibilitychange listener", () => { | ||
| it("triggers a check when the tab becomes visible", async () => { | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); | ||
|
Check warning on line 212 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(); | ||
| await flushPromises(); | ||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
|
Check warning on line 215 in src/ui/versionCheck.test.ts
|
||
|
|
||
| Object.defineProperty(document, "visibilityState", { | ||
| configurable: true, | ||
| value: "visible", | ||
| }); | ||
| document.dispatchEvent(new Event("visibilitychange")); | ||
| await flushPromises(); | ||
|
|
||
| expect(global.fetch).toHaveBeenCalledTimes(2); | ||
|
Check warning on line 224 in src/ui/versionCheck.test.ts
|
||
| }); | ||
|
|
||
| it("does not trigger a check when the tab becomes hidden", async () => { | ||
| global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); | ||
|
Check warning on line 228 in src/ui/versionCheck.test.ts
|
||
| startVersionPolling(); | ||
| await flushPromises(); | ||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
|
Check warning on line 231 in src/ui/versionCheck.test.ts
|
||
|
|
||
| Object.defineProperty(document, "visibilityState", { | ||
| configurable: true, | ||
| value: "hidden", | ||
| }); | ||
| document.dispatchEvent(new Event("visibilitychange")); | ||
| await flushPromises(); | ||
|
|
||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
|
Check warning on line 240 in src/ui/versionCheck.test.ts
|
||
| }); | ||
| }); | ||
|
|
||
| describe("forceRefresh", () => { | ||
| it("reloads the page", () => { | ||
| forceRefresh(); | ||
| expect(reloadSpy).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| it("deletes all caches before reloading", async () => { | ||
| const deleteSpy = vi.fn(); | ||
| Object.defineProperty(window, "caches", { | ||
|
Check warning on line 252 in src/ui/versionCheck.test.ts
|
||
| configurable: true, | ||
| value: { | ||
| keys: vi.fn().mockResolvedValue(["v1", "v2"]), | ||
| delete: deleteSpy, | ||
| }, | ||
| }); | ||
|
|
||
| forceRefresh(); | ||
| await flushPromises(); | ||
|
|
||
| expect(deleteSpy).toHaveBeenCalledWith("v1"); | ||
| expect(deleteSpy).toHaveBeenCalledWith("v2"); | ||
| expect(reloadSpy).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Return a cleanup from the polling effect.
startVersionPolling()registers long-lived state, but this effect never tears it down. In React Strict Mode or on remount, that can leave duplicate timers/listeners and duplicate update notifications.Have the helper return a stop function and return it from this effect.
🤖 Prompt for AI Agents