import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { useEvents } from "../auth/useEvents"; import { buildOf, onStreamReconnect, streamReconnected } from "../lib/buildWatch"; import { callsTo, mockFetch } from "../test/fetchMock"; import { UPDATED_TEXT, VersionBanner } from "./VersionBanner"; /** /api/version answers with whatever `now.build` holds at the time of the call. */ function server(first: string) { const now = { build: first }; const fetch = mockFetch({ "/api/version": () => ({ body: { build: now.build, stack: "ci", site_tz: "America/Denver" } }) }); const versionCalls = () => callsTo(fetch, "GET", "/api/version").length; return { now, fetch, versionCalls }; } describe("VersionBanner", () => { it("stays hidden while the build is the same, and shows on focus once it changes", async () => { const s = server("sha-aaa"); const onReload = vi.fn(); render(); await waitFor(() => expect(s.versionCalls()).toBe(1)); act(() => void window.dispatchEvent(new Event("focus"))); await waitFor(() => expect(s.versionCalls()).toBe(2)); expect(screen.queryByTestId("version-banner")).toBeNull(); s.now.build = "sha-bbb"; act(() => void window.dispatchEvent(new Event("focus"))); const banner = await screen.findByTestId("version-banner"); expect(banner).toHaveTextContent(UPDATED_TEXT); expect(banner).toHaveTextContent("Packet Station was updated. Reload to get the new version."); // Never reloads by itself. expect(onReload).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Reload" })); expect(onReload).toHaveBeenCalledTimes(1); }); it("checks again when the event stream reconnects", async () => { const s = server("1"); render(); await waitFor(() => expect(s.versionCalls()).toBe(1)); s.now.build = "2"; act(() => streamReconnected()); expect(await screen.findByTestId("version-banner")).toBeInTheDocument(); }); it("checks again on a timer", async () => { const s = server("1"); render(); await waitFor(() => expect(s.versionCalls()).toBe(1)); s.now.build = "2"; expect(await screen.findByTestId("version-banner")).toBeInTheDocument(); }); it("checks again when a hidden tab comes back", async () => { const s = server("1"); render(); await waitFor(() => expect(s.versionCalls()).toBe(1)); s.now.build = "2"; act(() => void document.dispatchEvent(new Event("visibilitychange"))); expect(await screen.findByTestId("version-banner")).toBeInTheDocument(); }); it("asks at most once per gap when focus comes in bursts", async () => { const s = server("1"); render(); await waitFor(() => expect(s.versionCalls()).toBe(1)); act(() => { window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("focus")); streamReconnected(); }); await new Promise((r) => setTimeout(r, 20)); expect(s.versionCalls()).toBe(1); }); it("rides out a server that is down or restarting", async () => { let answer: { status?: number; body: unknown } = { body: { build: "1" } }; const fetch = mockFetch({ "/api/version": () => answer }); render(); await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(1)); answer = { status: 502, body: { detail: "Bad gateway" } }; act(() => void window.dispatchEvent(new Event("focus"))); await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(2)); expect(screen.queryByTestId("version-banner")).toBeNull(); answer = { body: { build: "1" } }; act(() => void window.dispatchEvent(new Event("focus"))); await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(3)); expect(screen.queryByTestId("version-banner")).toBeNull(); answer = { body: { build: "2" } }; act(() => void window.dispatchEvent(new Event("focus"))); expect(await screen.findByTestId("version-banner")).toBeInTheDocument(); }); }); describe("buildOf", () => { it("reads build, falls back to version, and ignores odd shapes", () => { expect(buildOf({ build: " sha-1 ", version: "0.1" })).toBe("sha-1"); expect(buildOf({ version: "0.1" })).toBe("0.1"); expect(buildOf({ build: "", version: "" })).toBeNull(); expect(buildOf("1.2.3")).toBeNull(); expect(buildOf(null)).toBeNull(); }); }); describe("useEvents reconnect", () => { it("reports a reconnect on the second open of the same stream, not the first", () => { const sources: FakeEventSource[] = []; class FakeEventSource { listeners = new Map void>(); constructor() { sources.push(this); } addEventListener(type: string, fn: () => void) { this.listeners.set(type, fn); } close() {} } vi.stubGlobal("EventSource", FakeEventSource); const seen = vi.fn(); const off = onStreamReconnect(seen); renderHook(() => useEvents("sess-1", { onRevoked: () => undefined })); const es = sources.at(-1); act(() => es?.listeners.get("open")?.()); expect(seen).not.toHaveBeenCalled(); act(() => es?.listeners.get("open")?.()); expect(seen).toHaveBeenCalledTimes(1); off(); }); });