// @vitest-environment happy-dom // Mirror the IS_REACT_ACT_ENVIRONMENT setup from the other hook tests so // React doesn't log act() warnings about the createRoot render/unmount // and the popstate-driven state updates. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { useDrawerUrlState } from "./useDrawerUrlState"; /** * Minimal renderHook shim — same pattern as `useClaimDetail.test.ts`. * The project doesn't ship `@testing-library/react`, so we wire a Probe * component into a real `createRoot` and read its return value through * a shared `result` object. `act` flushes React's state updates between * micro-tasks so assertions after a popstate dispatch see the new value. */ function renderHook(setup: () => TResult): { result: { current: TResult | undefined }; unmount: () => void; } { const result: { current: TResult | undefined } = { current: undefined }; const container = document.createElement("div"); document.body.appendChild(container); function Probe() { result.current = setup(); return null; } const root: Root = createRoot(container); act(() => { root.render(React.createElement(Probe)); }); return { result, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** * Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a * writable `window.location.search`, but `window.happyDOM.setURL` updates * the URL the window reports without triggering a navigation — exactly * what we want for mounting the hook at `/claims?claim=CLM-1` etc. */ function setLocation(url: string): void { // happy-dom attaches the setURL helper under `window.happyDOM`. Cast // through `unknown` to keep the test file free of happy-dom imports. (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); } describe("useDrawerUrlState", () => { // `vi.fn()` with no args in vitest 4 is typed `Mock`, which carries a constructor signature and won't // assign to a real `pushState` slot. Constrain the generic to the // `History.pushState` signature so the mock flows into `vi.stubGlobal` // without complaint AND `mock.calls[0]` is typed as a 3-tuple, letting // us read `calls[0][2]` (the URL) without an `as unknown` cast. type PushState = (state: unknown, unused: string, url?: string | URL | null) => void; let pushStateMock: ReturnType>; let replaceStateMock: ReturnType>; beforeEach(() => { pushStateMock = vi.fn(); replaceStateMock = vi.fn(); // Replace `history` with a barebones stub. We only need pushState / // replaceState (and a state slot for the History spec). `vi.stubGlobal` // also affects `window.history` because happy-dom's window is the // global object. vi.stubGlobal("history", { pushState: pushStateMock, replaceState: replaceStateMock, state: null, }); }); afterEach(() => { vi.unstubAllGlobals(); // Reset happy-dom's URL between tests so one test's `?claim=…` doesn't // leak into the next test's "no param" assertion. setLocation("http://localhost/"); }); it("reads the ?claim= param from window.location.search on mount", () => { setLocation("http://localhost/claims?claim=CLM-1"); const { result, unmount } = renderHook(() => useDrawerUrlState()); expect(result.current?.claimId).toBe("CLM-1"); expect(typeof result.current?.open).toBe("function"); expect(typeof result.current?.close).toBe("function"); expect(typeof result.current?.setClaimId).toBe("function"); unmount(); }); it("returns null claimId when no ?claim= param is set", () => { // setLocation to a URL with no ?claim= (default afterLocation is // bare, but afterEach resets it explicitly — be defensive). setLocation("http://localhost/claims"); const { result, unmount } = renderHook(() => useDrawerUrlState()); expect(result.current?.claimId).toBeNull(); unmount(); }); it("returns null claimId when ?claim= is present but empty", () => { // Regression: `URLSearchParams.get` returns `""` for `?claim=`, which // a naive implementation would propagate as `claimId: ""`. We treat // empty as absent so the contract is "present-or-null", not // "present-or-empty-string". setLocation("http://localhost/claims?claim="); const { result, unmount } = renderHook(() => useDrawerUrlState()); expect(result.current?.claimId).toBeNull(); unmount(); }); it("open(id) pushes a new history entry containing ?claim=id", () => { setLocation("http://localhost/claims"); const { result, unmount } = renderHook(() => useDrawerUrlState()); act(() => { result.current?.open("CLM-2"); }); expect(pushStateMock).toHaveBeenCalledTimes(1); expect(replaceStateMock).not.toHaveBeenCalled(); // The third arg to pushState is the URL. The plan calls for a relative // URL built from pathname+search+hash, so it should contain the // ?claim= param. const urlArg = pushStateMock.mock.calls[0][2] as string; expect(urlArg).toContain("?claim=CLM-2"); unmount(); }); it("setClaimId(id) replaces the current history entry (no new entry) and does NOT pushState", () => { setLocation("http://localhost/claims?claim=CLM-1"); const { result, unmount } = renderHook(() => useDrawerUrlState()); act(() => { result.current?.setClaimId("CLM-3"); }); expect(replaceStateMock).toHaveBeenCalledTimes(1); expect(pushStateMock).not.toHaveBeenCalled(); const urlArg = replaceStateMock.mock.calls[0][2] as string; expect(urlArg).toContain("?claim=CLM-3"); unmount(); }); it("open() and close() preserve other query params (only ?claim= is touched)", () => { // Regression: `buildUrl` must only add/delete the `claim` key and // leave sibling params like `?foo=bar` intact. A bug here would mean // opening a claim nukes unrelated URL state — easy to miss without // an explicit assertion. setLocation("http://localhost/claims?foo=bar&claim=CLM-1"); const { result, unmount } = renderHook(() => useDrawerUrlState()); // open replaces the claim value but keeps foo. act(() => { result.current?.open("CLM-2"); }); const openUrl = pushStateMock.mock.calls[0][2] as string; expect(openUrl).toContain("foo=bar"); // `claim=` may be preceded by `?` or `&` depending on param order, // so just assert the param value appears. expect(openUrl).toMatch(/[?&]claim=CLM-2/); // close strips claim and keeps foo. act(() => { result.current?.close(); }); const closeUrl = pushStateMock.mock.calls[1][2] as string; expect(closeUrl).toContain("foo=bar"); expect(closeUrl).not.toContain("claim="); unmount(); }); it("close() pushes a new history entry with the ?claim= param stripped", () => { setLocation("http://localhost/claims?claim=CLM-1"); const { result, unmount } = renderHook(() => useDrawerUrlState()); act(() => { result.current?.close(); }); expect(pushStateMock).toHaveBeenCalledTimes(1); expect(replaceStateMock).not.toHaveBeenCalled(); const urlArg = pushStateMock.mock.calls[0][2] as string; // The plan's spec: close strips the param. After close, the URL should // not contain ?claim= (or contain it as empty — either way the param // is effectively gone). Our implementation deletes the param entirely. expect(urlArg).not.toContain("?claim="); // claimId must also flip back to null synchronously after close. expect(result.current?.claimId).toBeNull(); unmount(); }); it("updates claimId in response to popstate (browser back/forward)", async () => { // Start on CLM-1; simulate the user navigating forward to CLM-2 via // browser history (back/forward fires popstate). The hook must pick up // the new URL even though it didn't trigger the navigation itself. setLocation("http://localhost/claims?claim=CLM-1"); const { result, unmount } = renderHook(() => useDrawerUrlState()); expect(result.current?.claimId).toBe("CLM-1"); // Simulate browser back/forward: the URL changes and popstate fires. // happy-dom does not dispatch popstate automatically when setURL is // called, so we change the URL and dispatch the event ourselves — the // same shape a real browser would produce. setLocation("http://localhost/claims?claim=CLM-2"); await act(async () => { window.dispatchEvent(new PopStateEvent("popstate")); // Yield once so React's setState inside the listener flushes. await Promise.resolve(); }); expect(result.current?.claimId).toBe("CLM-2"); unmount(); }); });