Move dashboard client onto React Query hooks

Adds src/app/camera-queries.ts: React Query hooks over our own routes,
with each camera's queries keyed ['camera', id]. The camera card,
snapshot polling, login form and network scan use these hooks instead
of hand-rolled fetch/useEffect state (vrek iss-2fm6x2y, iss-ksxmctm,
iss-m032zwq, iss-8hfq2y2).

- Snapshot polling pauses in hidden tabs, never overlaps a slow
  frame, and stops after a failure until Retry. Each frame's object
  URL is created and revoked in one effect, so none leak under Strict
  Mode.
- Saving or forgetting a login resets only that camera's queries.
- New "Forget saved login" action, shown for stored logins.
- Fix: a scan timeout typed below 1 s now clamps to 1 s instead of
  falling back to 5 s.

Adds jsdom component tests (test/dom.tsx helpers): 93 tests, line
coverage 54.3%. Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 09:01:34 -05:00
parent 97a6b59cfe
commit 870b4cd2af
8 changed files with 681 additions and 184 deletions

View File

@ -199,3 +199,25 @@
{"id":"evt-zb67vbj9gq3f","type":"edge.added","subject":"iss-zjpc22k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-zjpc22k","to":"gol-9zxah3p"},"at":"2026-09-19T13:48:34.121Z","parents":["evt-b5118ev8dnhf"],"hash":"47a2a3f02b139eab3f36583eb856278791a46e9e6035cad834de35f8aeca3f49"}
{"id":"evt-9jqc4c9akpvd","type":"edge.added","subject":"iss-zjpc22k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-zjpc22k","to":"vitest.config.mts"},"at":"2026-09-19T13:48:34.122Z","parents":["evt-zb67vbj9gq3f"],"hash":"2dce8ab9c28c069b400e6bfbcbb1859ea66e49e72c4c36dab1e7c1386c2cd67e"}
{"id":"evt-tgnhg3bzctxe","type":"edge.added","subject":"iss-zjpc22k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-zjpc22k","to":"area:testing"},"at":"2026-09-19T13:48:34.123Z","parents":["evt-9jqc4c9akpvd"],"hash":"f5502f9006c88d881121d0b5ea5750d9a179ac754b1d53aab5469bb2e5d9ebaf"}
{"id":"evt-0ep7e88wpm1m","type":"node.created","subject":"ver-k6ndd19","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Scan runs through useScanNetwork (useMutation); on success it calls router.refresh() and shows the count; on failure it shows the error and does not refresh; the button is disabled while pending; the timeout is clamped to 1–30 s with an empty field meaning 5 s.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:30.768Z","parents":["evt-tgnhg3bzctxe"],"hash":"228254a84db314e5d0bcdccc8cf513bc5a457734f3cc8b3df2aad552de7ba5a7"}
{"id":"evt-gy197h670rnw","type":"edge.added","subject":"ver-k6ndd19","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-k6ndd19","to":"iss-2fm6x2y"},"at":"2026-09-19T13:58:30.771Z","parents":["evt-0ep7e88wpm1m"],"hash":"43d375d7ed15df69443fa05d8153467b55d16b7ced4d4ea339eee7b5c05188a5"}
{"id":"evt-anhn0b0z57dq","type":"verification.recorded","subject":"ver-k6ndd19","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-scanner.test.tsx (9 tests), `npx vitest run src/app/camera-scanner.test.tsx`, 2026-09-19. The test caught a real bug, fixed: a typed timeout under 1 s rounded to 0 and fell back to 5 s. Not exercised in a real browser."},"at":"2026-09-19T13:58:30.772Z","parents":["evt-gy197h670rnw"],"hash":"e51323701717157533b89c853bda296e5a91d3b71231980f2414b0a813714be1"}
{"id":"evt-s3m83caq68et","type":"node.created","subject":"ver-5f7rnb2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"CameraCard gets info via useCameraInfo (['camera', id, 'info']), with no useState/useEffect/attempt counter; auth → login form, inactive → setup panel, other failures retried once then shown with Retry; Retry resets the camera's queries.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:33.140Z","parents":["evt-anhn0b0z57dq"],"hash":"bb78b9114fa6856fd437db70a91590eda976e7814e2d6937f4a825089aafbe89"}
{"id":"evt-6by9j3pw9thh","type":"edge.added","subject":"ver-5f7rnb2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-5f7rnb2","to":"iss-ksxmctm"},"at":"2026-09-19T13:58:33.142Z","parents":["evt-s3m83caq68et"],"hash":"29bb2a1c1e5361c2379e3e3916e27ddeaf2bd2d6459b67a209587286e0cd1124"}
{"id":"evt-7628nyyf5hnp","type":"verification.recorded","subject":"ver-5f7rnb2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-card.test.tsx: 'retries a plain info failure once' (exactly 2 info fetches, 0 snapshot), 'shows setup steps…', 'asks for a login…', 'reports a network error…'. Hooks are in src/app/camera-queries.ts. 2026-09-19; not exercised in a real browser."},"at":"2026-09-19T13:58:33.143Z","parents":["evt-6by9j3pw9thh"],"hash":"335bc8d637583724617a6432fad5176a0c1cf22dba19dee1921fedf3efe4ba39"}
{"id":"evt-cbz0k7fvr9ez","type":"node.created","subject":"ver-e7k77p6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Snapshots poll via useCameraSnapshot (refetchInterval, only once info has loaded); every object URL is revoked, including on unmount; after a failed frame, polling stops with no further requests until Retry.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:36.163Z","parents":["evt-7628nyyf5hnp"],"hash":"e9417f97d166f402922124c794a56a667daf2d5685b2cc8fcce8c0b9f4b73c23"}
{"id":"evt-g0fcfn3fav15","type":"edge.added","subject":"ver-e7k77p6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-e7k77p6","to":"iss-m032zwq"},"at":"2026-09-19T13:58:36.164Z","parents":["evt-cbz0k7fvr9ez"],"hash":"f05e75428f603fe9d9e3d4ec616212532d1e75e1f13f16f2eea4ea68f39c8388"}
{"id":"evt-3ezj7nyaet8c","type":"verification.recorded","subject":"ver-e7k77p6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-card.test.tsx: 'frees every frame's object URL, including on unmount' (created ≥4, revoked set == created set) and 'stops polling after a failed frame' (0 extra requests over 150 ms at a 20 ms interval, then resumes after Retry). Interval refetches reuse an in-flight request (query-core queryObserver #executeFetch has no cancelRefetch), so a slow camera doesn't pile up requests. Object URLs are created and revoked in one effect, so they're safe under Strict Mode. 2026-09-19; the 'few minutes in a real browser' memory check was not run."},"at":"2026-09-19T13:58:36.165Z","parents":["evt-g0fcfn3fav15"],"hash":"e03c0fe1e2cb3aa4566b5d8b8ba37f18bd45a2f9f7b01cd152733972744893d9"}
{"id":"evt-q24pzht5swew","type":"node.created","subject":"ver-9z27bka","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The credentials GET is a useQuery (username prefill, never a password); PUT and DELETE are useMutations whose success resets only that camera's ['camera', id] queries; a rejected login shows as a form error.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:39.168Z","parents":["evt-3ezj7nyaet8c"],"hash":"f273b46aa473eb2403e6a8f5cb9d917b7504443207d8f39a70308bfb92647222"}
{"id":"evt-b83nqk81tsyh","type":"edge.added","subject":"ver-9z27bka","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-9z27bka","to":"iss-8hfq2y2"},"at":"2026-09-19T13:58:39.170Z","parents":["evt-q24pzht5swew"],"hash":"97ba093b1d98a4a277e711e86a7a2553ee23d6c57823b98b709914ff1359126f"}
{"id":"evt-dwsp2442bbq6","type":"verification.recorded","subject":"ver-9z27bka","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-card.test.tsx 'login' block (6 tests: prefill, typed username wins, rejected login, other failure, cancel, forget saved login via DELETE) and 'two cameras' (saving camera A refetches A's info, while B's info stays at 1 fetch). resetQueries is used rather than invalidateQueries so that error state clears. Adds a 'Forget saved login' button, shown only for stored logins. 2026-09-19; not exercised in a real browser."},"at":"2026-09-19T13:58:39.171Z","parents":["evt-b83nqk81tsyh"],"hash":"9fbc058401774aeb723683396a65a69e43b0cbbb3b2ecb8dd45dbaf7c7905786"}
{"id":"evt-edzjpeex02wj","type":"node.created","subject":"mea-s1kf91q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Line coverage of src/** (excluding page.tsx, *.d.ts, tests)","body":"`npm run coverage`, 2026-09-19, after the React Query migration and its component tests: 235/433 lines, 93 tests. At 100%: camera-card, camera-queries, camera-scanner, refresh-rate-select, refresh-rate, discover route, camera-route, credential-store. Remaining gaps: camera.ts 13.5%, onvif.ts 7.3%, the info/snapshot/credentials routes 0%, layout and providers 0%.","status":"recorded","owner":null,"attrs":{"value":54.27,"applies_at":"2026-09-19T13:58:41.458Z"}},"at":"2026-09-19T13:58:41.458Z","parents":["evt-dwsp2442bbq6"],"hash":"d758ed6a96554f9d760f0214fc04b50a34ae97bed44cdbdcc1570fc654d42d21"}
{"id":"evt-0wdpqz1n37pf","type":"edge.added","subject":"mea-s1kf91q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-s1kf91q","to":"gol-9zxah3p"},"at":"2026-09-19T13:58:41.459Z","parents":["evt-edzjpeex02wj"],"hash":"c5b74cc09fb476bed847f38fbd2a2dc5d25a10d92f4dfc00da775175a9f4d24b"}
{"id":"evt-4zw0z9bpe55j","type":"node.status_changed","subject":"iss-2fm6x2y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:44.403Z","parents":["evt-0wdpqz1n37pf"],"hash":"89ba0614e63465ee0a196031731d6d66fbd9510e1b99af04ca4b02f51a686557"}
{"id":"evt-4v0jw11be2tj","type":"node.status_changed","subject":"iss-ksxmctm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:45.459Z","parents":["evt-4zw0z9bpe55j"],"hash":"a112adef63186440af148e6b8f448284a136e185f04c0d4285603357e756fec2"}
{"id":"evt-0v8z82ap6vw8","type":"node.status_changed","subject":"iss-m032zwq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:46.508Z","parents":["evt-4v0jw11be2tj"],"hash":"2a1d315573688aa3502db53ecc5a39e073e4a9a6820861e01d710cf30db511b1"}
{"id":"evt-gdbnzswzw52f","type":"node.status_changed","subject":"iss-8hfq2y2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:47.573Z","parents":["evt-0v8z82ap6vw8"],"hash":"967a983d4444f32f6624a17b0cf15d2f520bf62e88b6a4fca57dd23a0dcd8904"}
{"id":"evt-5q5femhczhwy","type":"node.updated","subject":"iss-nc5w0j8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"title":"Component tests for providers and layout","body":"Mostly done alongside the React Query migration: camera-card, camera-queries, camera-scanner and refresh-rate-select are at 100% line coverage (test/dom.tsx has the render and fetch helpers). Remaining: providers.tsx (QueryClient defaults: staleTime 0, retry 1) and layout.tsx (mock next/font/google; check it wraps children in Providers)."}},"at":"2026-09-19T13:58:48.649Z","parents":["evt-gdbnzswzw52f"],"hash":"86128137c9d72e962190179549cc17c4970115c46958d76c06185693736ad65c"}
{"id":"evt-f6skgy2z97a7","type":"node.created","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser against real cameras, 'Change login' and 'Forget saved login' work end to end.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:01:24.882Z","parents":["evt-5q5femhczhwy"],"hash":"c6c5f6af9cf551e1cfeae733656cd853d9d712dccab3da0914b87198a99c90a8"}
{"id":"evt-2xx680y4h043","type":"edge.added","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-h8wgh7r","to":"iss-8hfq2y2"},"at":"2026-09-19T14:01:24.884Z","parents":["evt-f6skgy2z97a7"],"hash":"e276e752fb4db6dff1c9a931f93047d819a47f367298e1417c814aa43d89804a"}
{"id":"evt-e2r4bptapxrq","type":"verification.recorded","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual test by the user (Michael Mainguy) on 2026-09-19: both flows reported working."},"at":"2026-09-19T14:01:24.885Z","parents":["evt-2xx680y4h043"],"hash":"edcb6d01b6770914dc084b7542709bcbc8eba7129391cf6fc4a548191e7e05cd"}

View File

@ -0,0 +1,265 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraSummary } from "@/lib/camera-registry";
import { calls, json, renderWithQuery, stubFetch } from "../../test/dom";
import CameraCard from "./camera-card";
const cam: CameraSummary = {
id: "11111111-2222-3333-4444-555555555555",
host: "192.168.1.10",
port: 8080,
name: "Porch",
location: "Front door",
lastSeen: "2026-09-19T00:00:00.000Z",
};
const base = `/api/cameras/${cam.id}`;
const info = {
manufacturer: "Hikvision",
model: "DS-2CD",
firmwareVersion: "V5.7",
profiles: [{ token: "p1", name: "Main", encoding: "H264", width: 1920, height: 1080, fps: 25 }],
};
const frame = () => new Response(new Blob(["jpeg"], { type: "image/jpeg" }));
const noLogin = { source: "none", username: null, hasPassword: false };
// Count object URLs so leaks show up. Spies (restored by restoreMocks) rather than deletes,
// because a late effect from the previous test may still run during its cleanup.
let created: string[];
let revoked: string[];
beforeEach(() => {
created = [];
revoked = [];
let n = 0;
vi.spyOn(URL, "createObjectURL").mockImplementation(() => {
const url = `blob:frame-${++n}`;
created.push(url);
return url;
});
vi.spyOn(URL, "revokeObjectURL").mockImplementation((url: string) => void revoked.push(url));
});
describe("CameraCard", () => {
it("shows the camera's details, profiles and live frame", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
expect(screen.getByText("Porch")).toBeTruthy();
expect(screen.getByText("192.168.1.10:8080")).toBeTruthy();
expect(screen.getByText("Front door")).toBeTruthy();
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
expect(screen.getByText("1920×1080")).toBeTruthy();
await waitFor(() =>
expect(screen.getByRole("img", { name: "Porch" }).getAttribute("src")).toMatch(/^blob:/),
);
});
it("falls back to the model, then 'Unnamed camera', for the title", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={{ ...cam, name: undefined }} intervalMs={1000} />);
expect(screen.getByText("Unnamed camera")).toBeTruthy();
expect(await screen.findByText("DS-2CD")).toBeTruthy();
});
it("frees every frame's object URL, including on unmount", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
const { unmount } = renderWithQuery(<CameraCard cam={cam} intervalMs={20} />);
await waitFor(() => expect(created.length).toBeGreaterThanOrEqual(4));
unmount();
expect(revoked.sort()).toEqual([...created].sort());
});
it("stops polling after a failed frame and shows Retry", async () => {
let fail = false;
const fetchMock = stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: () => (fail ? json({ error: "Camera timed out" }, 502) : frame()),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={20} />);
await waitFor(() => expect(created.length).toBeGreaterThanOrEqual(1));
fail = true;
expect(await screen.findByText("Stopped: Camera timed out")).toBeTruthy();
const afterFailure = calls(fetchMock, `GET ${base}/snapshot`).length;
await new Promise((r) => setTimeout(r, 150));
// Not retried at all while stopped: an offline camera isn't hammered.
expect(calls(fetchMock, `GET ${base}/snapshot`).length).toBe(afterFailure);
fail = false;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(screen.queryByText(/Stopped:/)).toBeNull());
await waitFor(() =>
expect(calls(fetchMock, `GET ${base}/snapshot`).length).toBeGreaterThan(afterFailure),
);
});
it("retries a plain info failure once, then offers Retry", async () => {
const fetchMock = stubFetch({
[`GET ${base}/info`]: () => json({ error: "Camera unreachable" }, 502),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
expect(await screen.findByText("Stopped: Camera unreachable")).toBeTruthy();
expect(calls(fetchMock, `GET ${base}/info`)).toHaveLength(2);
expect(calls(fetchMock, `GET ${base}/snapshot`)).toHaveLength(0);
});
it("reports a network error from fetch itself", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
expect(await screen.findByText("Stopped: Failed to fetch")).toBeTruthy();
});
it("shows setup steps for an inactive camera, and checks again on request", async () => {
let active = false;
stubFetch({
[`GET ${base}/info`]: () =>
active ? json(info) : json({ error: "not activated", code: "inactive" }, 409),
[`GET ${base}/snapshot`]: frame,
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
expect(await screen.findByText("Setup required: camera not activated")).toBeTruthy();
active = true;
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
expect(screen.queryByText(/Setup required/)).toBeNull();
});
describe("login", () => {
it("asks for a login when the camera rejects it, then recovers after saving", async () => {
let loggedIn = false;
const fetchMock = stubFetch({
[`GET ${base}/info`]: () =>
loggedIn ? json(info) : json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/snapshot`]: frame,
[`GET ${base}/credentials`]: () => json({ ...noLogin, username: "saved-user" }),
[`PUT ${base}/credentials`]: () => {
loggedIn = true;
return json({ source: "stored", username: "saved-user", hasPassword: true });
},
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
expect(await screen.findByText("Wrong login")).toBeTruthy();
// Prefilled with the saved username; the password never comes back.
await waitFor(() =>
expect(screen.getByPlaceholderText<HTMLInputElement>("Username").value).toBe("saved-user"),
);
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
fireEvent.change(screen.getByPlaceholderText("Password"), { target: { value: "pw" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
expect(screen.queryByText("Camera login")).toBeNull();
const [, init] = calls(fetchMock, `PUT ${base}/credentials`)[0];
expect(JSON.parse(String(init?.body))).toEqual({ username: "saved-user", password: "pw" });
});
it("keeps a username the user typed over the saved one", async () => {
stubFetch({
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/credentials`]: () => json({ ...noLogin, username: "saved-user" }),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
const input = await screen.findByPlaceholderText<HTMLInputElement>("Username");
fireEvent.change(input, { target: { value: "typed" } });
await new Promise((r) => setTimeout(r, 20));
expect(input.value).toBe("typed");
});
it("shows a rejected login as a form error", async () => {
stubFetch({
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/credentials`]: () => json(noLogin),
[`PUT ${base}/credentials`]: () => json({ error: "401", code: "auth" }, 401),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
fireEvent.click(await screen.findByRole("button", { name: "Save" }));
expect(await screen.findByText("Camera rejected that login.")).toBeTruthy();
});
it("shows other save failures with the server's message", async () => {
stubFetch({
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/credentials`]: () => json(noLogin),
[`PUT ${base}/credentials`]: () => json({ error: "Camera unreachable" }, 502),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
fireEvent.click(await screen.findByRole("button", { name: "Save" }));
expect(await screen.findByText("Camera unreachable")).toBeTruthy();
});
it("opens from 'Change login' and can be cancelled", async () => {
stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: frame,
[`GET ${base}/credentials`]: () => json(noLogin),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
fireEvent.click(await screen.findByRole("button", { name: "Change login" }));
expect(screen.getByText("Camera login")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Forget saved login" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Camera login")).toBeNull();
});
it("forgets a stored login and re-checks the camera", async () => {
let stored = true;
const fetchMock = stubFetch({
[`GET ${base}/info`]: () =>
stored ? json(info) : json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/snapshot`]: frame,
[`GET ${base}/credentials`]: () =>
json(stored ? { source: "stored", username: "admin", hasPassword: true } : noLogin),
[`DELETE ${base}/credentials`]: () => {
stored = false;
return json(noLogin);
},
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
fireEvent.click(await screen.findByRole("button", { name: "Change login" }));
fireEvent.click(await screen.findByRole("button", { name: "Forget saved login" }));
expect(await screen.findByText("Wrong login")).toBeTruthy();
expect(calls(fetchMock, `DELETE ${base}/credentials`)).toHaveLength(1);
});
});
});
describe("two cameras", () => {
it("saving one camera's login refetches only that camera", async () => {
const other = { ...cam, id: "99999999-2222-3333-4444-555555555555", name: "Yard" };
const otherBase = `/api/cameras/${other.id}`;
const fetchMock = stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: frame,
[`GET ${base}/credentials`]: () => json(noLogin),
[`PUT ${base}/credentials`]: () =>
json({ source: "stored", username: "camera", hasPassword: true }),
[`GET ${otherBase}/info`]: () => json(info),
[`GET ${otherBase}/snapshot`]: frame,
});
renderWithQuery(
<>
<CameraCard cam={cam} intervalMs={60_000} />
<CameraCard cam={other} intervalMs={60_000} />
</>,
);
await waitFor(() => expect(screen.getAllByText("Hikvision DS-2CD · fw V5.7")).toHaveLength(2));
fireEvent.click(screen.getAllByRole("button", { name: "Change login" })[0]);
fireEvent.click(await screen.findByRole("button", { name: "Save" }));
await waitFor(() => expect(calls(fetchMock, `GET ${base}/info`)).toHaveLength(2));
expect(calls(fetchMock, `GET ${otherBase}/info`)).toHaveLength(1);
});
});

View File

@ -1,24 +1,19 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { CameraInfo } from "@/lib/camera";
import type { CameraSummary } from "@/lib/camera-registry";
type Problem = { kind: "inactive" | "auth" | "error"; message: string };
import {
useCameraInfo,
useCameraSnapshot,
useClearCredentials,
useCredentialStatus,
useResetCamera,
useSaveCredentials,
type CameraProblem,
} from "./camera-queries";
const DEFAULT_USERNAME = "camera";
async function problemFrom(res: Response): Promise<Problem> {
const data = await res.json().catch(() => ({}));
const kind =
data.code === "inactive" ? "inactive" : res.status === 401 || data.code === "auth" ? "auth" : "error";
return { kind, message: data.error ?? `HTTP ${res.status}` };
}
function apiUrl(cam: CameraSummary, path: string) {
return `/api/cameras/${cam.id}/${path}`;
}
/** The camera's own web interface; it can't be framed (X-Frame-Options: SAMEORIGIN). */
function webUiUrl(cam: CameraSummary) {
return `http://${cam.host}${cam.port === 80 ? "" : `:${cam.port}`}/`;
@ -64,72 +59,26 @@ function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: (
);
}
/**
* Polls the snapshot route, requesting the next frame only after the current one has
* arrived. Stops on the first failure and reports it; the parent restarts it by
* clearing `paused`.
*/
function Snapshot({
cam,
intervalMs,
paused,
onProblem,
}: {
cam: CameraSummary;
intervalMs: number;
paused: boolean;
onProblem: (p: Problem) => void;
}) {
const [frame, setFrame] = useState<string | null>(null);
const frameRef = useRef<string | null>(null);
const onProblemRef = useRef(onProblem);
/** Shows the latest frame; polling itself lives in useCameraSnapshot. */
function Snapshot({ cam, frame, paused }: { cam: CameraSummary; frame?: Blob; paused: boolean }) {
const imgRef = useRef<HTMLImageElement>(null);
// Each frame gets one object URL, created and revoked in the same effect so it is freed
// exactly once, including under Strict Mode's double rendering.
useEffect(() => {
onProblemRef.current = onProblem;
});
useEffect(() => () => {
if (frameRef.current) URL.revokeObjectURL(frameRef.current);
}, []);
useEffect(() => {
if (paused) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function poll() {
try {
const res = await fetch(apiUrl(cam, "snapshot"), { cache: "no-store" });
if (cancelled) return;
if (!res.ok) return onProblemRef.current(await problemFrom(res));
const url = URL.createObjectURL(await res.blob());
if (cancelled) return URL.revokeObjectURL(url);
if (frameRef.current) URL.revokeObjectURL(frameRef.current);
frameRef.current = url;
setFrame(url);
timer = setTimeout(poll, intervalMs);
} catch (err) {
if (!cancelled) {
onProblemRef.current({
kind: "error",
message: err instanceof Error ? err.message : String(err),
});
}
}
}
poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [cam, intervalMs, paused]);
const img = imgRef.current;
if (!frame || !img) return;
const url = URL.createObjectURL(frame);
img.src = url;
return () => URL.revokeObjectURL(url);
}, [frame]);
return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
{frame && (
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
<img
src={frame}
ref={imgRef}
alt={cam.name ?? cam.host}
className={`h-full w-full object-contain ${paused ? "opacity-40" : ""}`}
/>
@ -151,51 +100,34 @@ function CredentialsForm({
}: {
cam: CameraSummary;
reason?: string;
/** Called after a save or clear; the camera's queries have already been reset. */
onSaved: () => void;
onCancel?: () => void;
}) {
const [username, setUsername] = useState(DEFAULT_USERNAME);
const status = useCredentialStatus(cam.id);
const save = useSaveCredentials(cam.id);
const clear = useClearCredentials(cam.id);
// null until the user types, so the saved username (never the password) can prefill it.
const [typedUsername, setTypedUsername] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const username = typedUsername ?? status.data?.username ?? DEFAULT_USERNAME;
// Prefill the username only; the API never returns passwords.
useEffect(() => {
fetch(apiUrl(cam, "credentials"))
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
// A username the user has already typed wins over the saved one.
if (data?.username) {
setUsername((u) => (u === DEFAULT_USERNAME ? data.username : u));
}
})
.catch(() => {});
}, [cam]);
async function submit(e: React.FormEvent) {
function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const res = await fetch(apiUrl(cam, "credentials"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const problem = await problemFrom(res);
setError(problem.kind === "auth" ? "Camera rejected that login." : problem.message);
return;
}
setPassword("");
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
save.mutate(
{ username, password },
{
onSuccess: () => {
setPassword("");
onSaved();
},
},
);
}
const error = save.error ?? clear.error;
const busy = save.isPending || clear.isPending;
return (
<form onSubmit={submit} className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="font-medium">Camera login</div>
@ -206,7 +138,7 @@ function CredentialsForm({
</div>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
onChange={(e) => setTypedUsername(e.target.value)}
placeholder="Username"
autoComplete="off"
required
@ -220,15 +152,29 @@ function CredentialsForm({
autoComplete="new-password"
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
{error && <div className="text-red-600">{error}</div>}
{error && (
<div className="text-red-600">
{error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
disabled={busy}
className="rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{saving ? "Checking…" : "Save"}
{save.isPending ? "Checking…" : "Save"}
</button>
{status.data?.source === "stored" && (
<button
type="button"
disabled={busy}
onClick={() => clear.mutate(undefined, { onSuccess: onSaved })}
className="rounded px-3 py-1 underline disabled:opacity-50"
>
Forget saved login
</button>
)}
{onCancel && (
<button type="button" onClick={onCancel} className="rounded px-3 py-1 underline">
Cancel
@ -246,32 +192,15 @@ export default function CameraCard({
cam: CameraSummary;
intervalMs: number;
}) {
const [info, setInfo] = useState<CameraInfo | null>(null);
const [problem, setProblem] = useState<Problem | null>(null);
const info = useCameraInfo(cam.id);
const snapshot = useCameraSnapshot(cam.id, intervalMs, info.isSuccess);
const resetCamera = useResetCamera(cam.id);
const [editingLogin, setEditingLogin] = useState(false);
// Bumped to retry after a failure or a credentials change.
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
fetch(apiUrl(cam, "info"))
.then(async (res) => {
if (cancelled) return;
if (!res.ok) return setProblem(await problemFrom(res));
setInfo(await res.json());
})
.catch((err) => {
if (!cancelled) setProblem({ kind: "error", message: String(err) });
});
return () => {
cancelled = true;
};
}, [cam, attempt]);
const problem: CameraProblem | null = info.error ?? snapshot.error;
function retry() {
setProblem(null);
setEditingLogin(false);
setAttempt((a) => a + 1);
resetCamera();
}
const showSetup = problem?.kind === "inactive";
@ -279,16 +208,10 @@ export default function CameraCard({
return (
<li className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<Snapshot
key={attempt}
cam={cam}
intervalMs={intervalMs}
paused={problem !== null || !info}
onProblem={setProblem}
/>
<Snapshot cam={cam} frame={snapshot.data} paused={problem !== null || !info.isSuccess} />
<div>
<div className="font-medium">{cam.name ?? info?.model ?? "Unnamed camera"}</div>
<div className="font-medium">{cam.name ?? info.data?.model ?? "Unnamed camera"}</div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm">
{cam.host}:{cam.port}
@ -302,9 +225,9 @@ export default function CameraCard({
)}
</span>
</div>
{info?.manufacturer && (
{info.data?.manufacturer && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{info.manufacturer} {info.model} · fw {info.firmwareVersion}
{info.data.manufacturer} {info.data.model} · fw {info.data.firmwareVersion}
</div>
)}
{cam.location && (
@ -318,7 +241,7 @@ export default function CameraCard({
<CredentialsForm
cam={cam}
reason={problem?.kind === "auth" ? problem.message : undefined}
onSaved={retry}
onSaved={() => setEditingLogin(false)}
onCancel={problem?.kind === "auth" ? undefined : () => setEditingLogin(false)}
/>
)}
@ -332,7 +255,7 @@ export default function CameraCard({
</div>
)}
{info && info.profiles.length > 0 && (
{info.data && info.data.profiles.length > 0 && (
<table className="text-xs">
<thead className="text-left text-zinc-500">
<tr>
@ -343,7 +266,7 @@ export default function CameraCard({
</tr>
</thead>
<tbody className="font-mono">
{info.profiles.map((p) => (
{info.data.profiles.map((p) => (
<tr key={p.token}>
<td>{p.name ?? p.token}</td>
<td>{p.encoding ?? "?"}</td>

151
src/app/camera-queries.ts Normal file
View File

@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CameraInfo } from "@/lib/camera";
import type { DiscoveredCamera } from "@/lib/onvif";
/**
* Client-side data layer for the dashboard: React Query hooks over our own route
* handlers. No device logic lives here; the server decides what each response means.
*/
/** What the UI acts on: "inactive" shows setup steps, "auth" asks for a login. */
export type ProblemKind = "inactive" | "auth" | "error";
export class CameraProblem extends Error {
name = "CameraProblem";
constructor(
readonly kind: ProblemKind,
message: string,
) {
super(message);
}
}
export interface CredentialStatus {
source: "stored" | "env" | "none";
username: string | null;
hasPassword: boolean;
}
export interface Login {
username: string;
password: string;
}
/** Every query for one camera shares this prefix, so a change can reset them together. */
export const cameraKeys = {
all: (id: string) => ["camera", id] as const,
info: (id: string) => ["camera", id, "info"] as const,
snapshot: (id: string) => ["camera", id, "snapshot"] as const,
credentials: (id: string) => ["camera", id, "credentials"] as const,
};
const cameraUrl = (id: string, path: string) => `/api/cameras/${id}/${path}`;
async function problemFrom(res: Response): Promise<CameraProblem> {
const data = await res.json().catch(() => ({}));
const kind: ProblemKind =
data.code === "inactive"
? "inactive"
: res.status === 401 || data.code === "auth"
? "auth"
: "error";
return new CameraProblem(kind, data.error ?? `HTTP ${res.status}`);
}
async function request(url: string, init?: RequestInit): Promise<Response> {
const res = await fetch(url, { cache: "no-store", ...init }).catch((err: unknown) => {
throw new CameraProblem("error", err instanceof Error ? err.message : String(err));
});
if (!res.ok) throw await problemFrom(res);
return res;
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
return (await request(url, init)).json() as Promise<T>;
}
/** Only plain failures are worth one retry; a login or setup problem needs the user. */
const retryPlainFailureOnce = (failures: number, err: Error) =>
failures < 1 && !(err instanceof CameraProblem && err.kind !== "error");
export function useCameraInfo(id: string) {
return useQuery<CameraInfo, CameraProblem>({
queryKey: cameraKeys.info(id),
queryFn: () => requestJson(cameraUrl(id, "info")),
retry: retryPlainFailureOnce,
});
}
/**
* Polls the camera's snapshot every `intervalMs` while `enabled`. A slow frame is never
* requested twice at once, polling pauses in hidden tabs, and it stops on the first
* failure so an offline camera isn't hammered; reset the camera's queries to restart.
*/
export function useCameraSnapshot(id: string, intervalMs: number, enabled: boolean) {
return useQuery<Blob, CameraProblem>({
queryKey: cameraKeys.snapshot(id),
queryFn: async () => (await request(cameraUrl(id, "snapshot"))).blob(),
enabled,
retry: false,
refetchInterval: (query) => (query.state.status === "error" ? false : intervalMs),
// Frames are only useful while shown; drop them as soon as the card unmounts.
gcTime: 0,
});
}
export function useCredentialStatus(id: string) {
return useQuery<CredentialStatus, CameraProblem>({
queryKey: cameraKeys.credentials(id),
queryFn: () => requestJson(cameraUrl(id, "credentials")),
retry: false,
});
}
/** Clears every query for the camera and refetches the ones on screen. */
export function useResetCamera(id: string) {
const queryClient = useQueryClient();
return () => queryClient.resetQueries({ queryKey: cameraKeys.all(id) });
}
/** Saves a login the camera has accepted; the server tests it before storing. */
export function useSaveCredentials(id: string) {
const reset = useResetCamera(id);
return useMutation<CredentialStatus, CameraProblem, Login>({
mutationFn: (login) =>
requestJson(cameraUrl(id, "credentials"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(login),
}),
onSuccess: reset,
});
}
export function useClearCredentials(id: string) {
const reset = useResetCamera(id);
return useMutation<CredentialStatus, CameraProblem, void>({
mutationFn: () => requestJson(cameraUrl(id, "credentials"), { method: "DELETE" }),
onSuccess: reset,
});
}
export interface ScanOptions {
timeoutSecs: number;
unicastSweep: boolean;
}
export function useScanNetwork() {
return useMutation<{ cameras: DiscoveredCamera[] }, CameraProblem, ScanOptions>({
mutationFn: ({ timeoutSecs, unicastSweep }) =>
requestJson("/api/discover", {
method: "POST",
headers: { "Content-Type": "application/json" },
// The server rejects timeouts outside 1–30 s; keep a half-typed value in range.
// An emptied field reads as 0 and means "use the default".
body: JSON.stringify({
timeout: (timeoutSecs > 0 ? Math.min(Math.max(Math.round(timeoutSecs), 1), 30) : 5) * 1000,
unicastSweep,
}),
}),
});
}

View File

@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { calls, json, renderWithQuery, stubFetch } from "../../test/dom";
import CameraScanner from "./camera-scanner";
const refresh = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
const body = (fetchMock: ReturnType<typeof stubFetch>) =>
JSON.parse(String(calls(fetchMock, "POST /api/discover")[0][1]?.body));
describe("CameraScanner", () => {
beforeEach(() => refresh.mockReset());
it("scans, reports the count and refreshes the server-rendered list", async () => {
const fetchMock = stubFetch({
"POST /api/discover": () => json({ cameras: [{ id: "a" }, { id: "b" }] }),
});
renderWithQuery(<CameraScanner />);
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
expect(await screen.findByText("Scan found 2 cameras")).toBeTruthy();
expect(refresh).toHaveBeenCalledTimes(1);
expect(body(fetchMock)).toEqual({ timeout: 5000, unicastSweep: true });
});
it("uses the singular for one camera", async () => {
stubFetch({ "POST /api/discover": () => json({ cameras: [{ id: "a" }] }) });
renderWithQuery(<CameraScanner />);
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
expect(await screen.findByText("Scan found 1 camera")).toBeTruthy();
});
it("disables the button while scanning", async () => {
let finish!: (res: Response) => void;
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });
renderWithQuery(<CameraScanner />);
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "Scanning…" });
expect(button.disabled).toBe(true);
finish(json({ cameras: [] }));
await screen.findByRole("button", { name: "Scan network" });
});
it.each([
["an in-range value", "12", 12_000],
["a value above 30 s", "90", 30_000],
["a value below 1 s", "0.2", 1000],
["an empty field", "", 5000],
])("sends the timeout clamped to 1–30 s for %s", async (_label, typed, expected) => {
const fetchMock = stubFetch({ "POST /api/discover": () => json({ cameras: [] }) });
renderWithQuery(<CameraScanner />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: typed } });
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
await waitFor(() => expect(calls(fetchMock, "POST /api/discover")).toHaveLength(1));
expect(body(fetchMock)).toEqual({ timeout: expected, unicastSweep: false });
});
it("shows a failed scan and does not refresh", async () => {
stubFetch({ "POST /api/discover": () => json({ error: "Network scan failed" }, 500) });
renderWithQuery(<CameraScanner />);
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
expect(await screen.findByText("Error: Network scan failed")).toBeTruthy();
expect(refresh).not.toHaveBeenCalled();
});
it("falls back to the HTTP status when the error body isn't JSON", async () => {
stubFetch({ "POST /api/discover": () => new Response("oops", { status: 503 }) });
renderWithQuery(<CameraScanner />);
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
expect(await screen.findByText("Error: HTTP 503")).toBeTruthy();
});
});

View File

@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useScanNetwork } from "./camera-queries";
/**
* Runs a network scan. The scan writes the server-side registry, so afterwards the
@ -9,46 +10,22 @@ import { useState } from "react";
*/
export default function CameraScanner() {
const router = useRouter();
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [timeout, setTimeoutSecs] = useState(5);
const scan = useScanNetwork();
const [timeoutSecs, setTimeoutSecs] = useState(5);
const [unicastSweep, setUnicastSweep] = useState(true);
async function scan() {
setScanning(true);
setError(null);
setResult(null);
try {
const res = await fetch("/api/discover", {
method: "POST",
headers: { "Content-Type": "application/json" },
// The server rejects timeouts outside 1–30 s; keep a half-typed value in range.
body: JSON.stringify({
timeout: Math.min(Math.max(Math.round(timeout) || 5, 1), 30) * 1000,
unicastSweep,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
const found = data.cameras.length;
setResult(`Scan found ${found} camera${found === 1 ? "" : "s"}`);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setScanning(false);
}
}
const found = scan.data?.cameras.length;
return (
<>
<button
onClick={scan}
disabled={scanning}
onClick={() =>
scan.mutate({ timeoutSecs, unicastSweep }, { onSuccess: () => router.refresh() })
}
disabled={scan.isPending}
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{scanning ? "Scanning…" : "Scan network"}
{scan.isPending ? "Scanning…" : "Scan network"}
</button>
<label className="flex items-center gap-2 text-sm">
Timeout
@ -56,7 +33,7 @@ export default function CameraScanner() {
type="number"
min={1}
max={30}
value={timeout}
value={timeoutSecs}
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
@ -70,8 +47,12 @@ export default function CameraScanner() {
/>
Unicast sweep of local subnet
</label>
{result && <p className="w-full text-sm text-zinc-600 dark:text-zinc-400">{result}</p>}
{error && <p className="w-full text-sm text-red-600">Error: {error}</p>}
{found !== undefined && (
<p className="w-full text-sm text-zinc-600 dark:text-zinc-400">
Scan found {found} camera{found === 1 ? "" : "s"}
</p>
)}
{scan.isError && <p className="w-full text-sm text-red-600">Error: {scan.error.message}</p>}
</>
);
}

View File

@ -0,0 +1,27 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import RefreshRateSelect from "./refresh-rate-select";
const replace = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace }),
usePathname: () => "/",
}));
afterEach(cleanup);
describe("RefreshRateSelect", () => {
it("shows the current rate and every option in seconds", () => {
render(<RefreshRateSelect value={500} />);
const select = screen.getByRole<HTMLSelectElement>("combobox");
expect(select.value).toBe("500");
expect([...select.options].map((o) => o.text)).toEqual(["0.25 s", "0.5 s", "1 s", "2 s", "5 s"]);
});
it("writes the chosen rate to the URL without scrolling", () => {
render(<RefreshRateSelect value={1000} />);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "250" } });
expect(replace).toHaveBeenCalledWith("/?refresh=250", { scroll: false });
});
});

49
test/dom.tsx Normal file
View File

@ -0,0 +1,49 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, vi } from "vitest";
afterEach(cleanup);
/** Same defaults as the app's Providers, but retries fire immediately. */
export function testQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 0, retry: 1, retryDelay: 0 },
mutations: { retry: false },
},
});
}
export function renderWithQuery(ui: ReactNode, client = testQueryClient()) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return { client, ...render(ui, { wrapper }) };
}
type Handler = (init: RequestInit | undefined) => Response | Promise<Response>;
/**
* Stubs global fetch with handlers keyed by "METHOD /path". Unmatched requests fail the
* test loudly instead of hitting the network.
*/
export function stubFetch(routes: Record<string, Handler>) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input), "http://localhost");
const key = `${init?.method ?? "GET"} ${url.pathname}`;
const handler = routes[key];
if (!handler) throw new Error(`Unexpected fetch: ${key}`);
return handler(init);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
export const json = (body: unknown, status = 200) => Response.json(body, { status });
export const calls = (fetchMock: ReturnType<typeof stubFetch>, key: string) =>
fetchMock.mock.calls.filter(
([input, init]) =>
`${init?.method ?? "GET"} ${new URL(String(input), "http://localhost").pathname}` === key,
);