// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProjectSignaturePoll } from "./useProjectSignaturePoll";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
function HookHost({
signature,
onChange,
}: {
signature: string | undefined;
onChange: () => void;
}) {
useProjectSignaturePoll("demo", signature, onChange);
return null;
}
let root: Root | null = null;
let container: HTMLElement | null = null;
function mount(signature: string | undefined, onChange: () => void): void {
act(() => {
if (!root) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
}
root.render();
});
}
function unmount(): void {
if (root) {
act(() => root?.unmount());
root = null;
}
container?.remove();
container = null;
}
function mockSignatureResponse(signature: string): ReturnType {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ signature }) }));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("useProjectSignaturePoll", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
unmount();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("fires onChange when the polled signature moves past the loaded one", async () => {
const fetchMock = mockSignatureResponse("sig-b");
const onChange = vi.fn();
mount("sig-a", onChange);
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/signature");
expect(onChange).toHaveBeenCalledTimes(1);
});
it("stays quiet while the polled signature matches", async () => {
mockSignatureResponse("sig-a");
const onChange = vi.fn();
mount("sig-a", onChange);
await vi.advanceTimersByTimeAsync(6000);
expect(onChange).not.toHaveBeenCalled();
});
it("does not poll before a baseline signature exists", async () => {
const fetchMock = mockSignatureResponse("sig-a");
const onChange = vi.fn();
mount(undefined, onChange);
await vi.advanceTimersByTimeAsync(6000);
expect(fetchMock).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
});
it("re-arms against the caller's refreshed signature without re-mounting", async () => {
mockSignatureResponse("sig-b");
const onChange = vi.fn();
mount("sig-a", onChange);
await vi.advanceTimersByTimeAsync(2000);
expect(onChange).toHaveBeenCalledTimes(1);
// The caller refetched and now holds the polled signature — no more firing.
mount("sig-b", onChange);
await vi.advanceTimersByTimeAsync(6000);
expect(onChange).toHaveBeenCalledTimes(1);
});
it("skips ticks while the tab is hidden and re-checks on visibility", async () => {
const fetchMock = mockSignatureResponse("sig-b");
const onChange = vi.fn();
let hidden = true;
vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
mount("sig-a", onChange);
await vi.advanceTimersByTimeAsync(6000);
expect(fetchMock).not.toHaveBeenCalled();
hidden = false;
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(0);
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it("stops polling after unmount", async () => {
mockSignatureResponse("sig-b");
const onChange = vi.fn();
mount("sig-a", onChange);
await vi.advanceTimersByTimeAsync(2000);
expect(onChange).toHaveBeenCalledTimes(1);
unmount();
await vi.advanceTimersByTimeAsync(10_000);
expect(onChange).toHaveBeenCalledTimes(1);
});
});