diff --git a/.github/workflows/deploy-qa.yml b/.github/workflows/deploy-qa.yml index 968e89f7..a3d0e6de 100644 --- a/.github/workflows/deploy-qa.yml +++ b/.github/workflows/deploy-qa.yml @@ -74,6 +74,7 @@ jobs: HUSKY: "0" VITE_RUN_ENVIRONMENT: dev RunEnvironment: dev + VITE_BUILD_HASH: ${{ github.event.pull_request.head.sha || github.sha }} - name: Upload Build files uses: actions/upload-artifact@v7 diff --git a/package.json b/package.json index dea401a2..0740ae2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "infra-core", - "version": "4.9.6", + "version": "4.9.7", "private": true, "type": "module", "workspaces": [ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2459119f..e3f2b2c5 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2,14 +2,16 @@ import "@mantine/core/styles.css"; import "@mantine/dropzone/styles.css"; import "@mantine/notifications/styles.css"; import "@mantine/dates/styles.css"; -import { MantineProvider } from "@mantine/core"; +import { Button, Group, MantineProvider, Text } from "@mantine/core"; import { cssVariablesResolver, theme } from "./theme"; import { useColorScheme, useLocalStorage } from "@mantine/hooks"; -import { Notifications } from "@mantine/notifications"; +import { Notifications, notifications } from "@mantine/notifications"; +import { useEffect } from "react"; import ColorSchemeContext from "./ColorSchemeContext"; import { Router } from "./Router"; import { UserResolverProvider } from "./components/NameOptionalCard"; +import { forceRefresh, startVersionPolling } from "./versionCheck"; export default function App() { const preferredColorScheme = useColorScheme(); @@ -17,6 +19,26 @@ export default function App() { key: "acm-manage-color-scheme", defaultValue: preferredColorScheme, }); + + useEffect(() => { + startVersionPolling(() => { + notifications.show({ + id: "version-update", + title: "Update available", + message: ( + + A new version of the app is available. + + + ), + color: "blue", + autoClose: false, + }); + }); + }, []); + return ( 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; + 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", { + configurable: true, + value: { reload: reloadSpy }, + }); + Object.defineProperty(window, "caches", { + 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)); + startVersionPolling(); + await flushPromises(); + expect(global.fetch).toHaveBeenCalledOnce(); + }); + + it("rechecks every 5 minutes after the initial check", async () => { + global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); + startVersionPolling(); + await flushPromises(); + expect(global.fetch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(global.fetch).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + + it("registers a visibilitychange listener", () => { + global.fetch = vi.fn().mockReturnValue(new Promise(() => {})); + 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)); + 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)); + 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)); + 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)); + 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)); + 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 + .fn() + .mockReturnValue(makeResponse(null, { contentType: "text/html" })); + startVersionPolling(); + await flushPromises(); + expect(global.fetch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("does nothing when response is not ok", async () => { + const onUpdate = vi.fn(); + global.fetch = vi + .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")); + 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); + }); + + it("stops polling after 10 consecutive failures", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("network error")); + 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).mock.calls + .length; + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect((global.fetch as ReturnType).mock.calls.length).toBe( + callCount, + ); + }); + }); + + describe("visibilitychange listener", () => { + it("triggers a check when the tab becomes visible", async () => { + global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); + startVersionPolling(); + await flushPromises(); + expect(global.fetch).toHaveBeenCalledTimes(1); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + await flushPromises(); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it("does not trigger a check when the tab becomes hidden", async () => { + global.fetch = vi.fn().mockReturnValue(makeResponse(CURRENT)); + startVersionPolling(); + await flushPromises(); + expect(global.fetch).toHaveBeenCalledTimes(1); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + await flushPromises(); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + + 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", { + 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(); + }); + }); +}); diff --git a/src/ui/versionCheck.ts b/src/ui/versionCheck.ts new file mode 100644 index 00000000..708eeb54 --- /dev/null +++ b/src/ui/versionCheck.ts @@ -0,0 +1,72 @@ +const CURRENT_VERSION = import.meta.env.VITE_BUILD_HASH; + +let intervalId: any; +let fetchFailures = 0; +let onUpdateCallback: (() => void) | null = null; + +async function checkForUpdate() { + try { + const res = await fetch("/version.json", { cache: "no-store" }); + + if (!res.ok) { + return; + } + + const contentType = res.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + // SPA fallback returned HTML or something else — version.json isn't deployed + console.warn("Invalid response for version.json"); + stopPolling(); + return; + } + + const { version } = await res.json(); + fetchFailures = 0; + if (version && CURRENT_VERSION && version !== CURRENT_VERSION) { + stopPolling(); + if (onUpdateCallback) { + onUpdateCallback(); + } else { + forceRefresh(); + } + } + } catch (e) { + fetchFailures++; + if (fetchFailures > 10) { + console.warn("Failed to fetch version 10 times; giving up."); + stopPolling(); + } else { + console.warn("Failed to fetch version of current system: ", e); + } + } +} + +export function forceRefresh() { + if ("caches" in window) { + caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))); + } + window.location.reload(); +} + +function stopPolling() { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +} + +function onVisibilityChange() { + if (document.visibilityState === "visible") { + checkForUpdate(); + } +} + +export function startVersionPolling(onUpdate?: () => void) { + if (onUpdate) { + onUpdateCallback = onUpdate; + } + checkForUpdate(); + intervalId = setInterval(checkForUpdate, 5 * 60 * 1000); + document.addEventListener("visibilitychange", onVisibilityChange); +} diff --git a/src/ui/vite.config.mjs b/src/ui/vite.config.mjs index 8559ae6e..a0bae05f 100644 --- a/src/ui/vite.config.mjs +++ b/src/ui/vite.config.mjs @@ -3,9 +3,24 @@ import react from "@vitejs/plugin-react"; import "dotenv/config"; import path from "path"; +const VERSION_PLUGIN = { + name: "emit-version-json", + apply: "build", + generateBundle() { + const version = process.env.VITE_BUILD_HASH; + if (!version) return; + + this.emitFile({ + type: "asset", + fileName: "version.json", + source: JSON.stringify({ version }), + }); + }, +}; + export default defineConfig({ define: { "process.env": { AWS_REGION: process.env.AWS_REGION } }, - plugins: [react()], + plugins: [react(), VERSION_PLUGIN], resolve: { tsconfigPaths: true, preserveSymlinks: true,