// @vitest-environment jsdom import { fireEvent, screen, waitFor } from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CameraSummary } from "@/lib/camera-registry"; import { json, renderWithQuery, stubFetch } from "../../test/dom"; /** * Icons (vrek gol-m8je1wc, dec-1vnz5fw): each control and warning carries the agreed * Lucide icon, icons are always decorative (aria-hidden), and accessible names stay the * visible text. */ vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }), usePathname: () => "/", redirect: (to: string) => { throw new Error(`redirect ${to}`); }, })); const getAdmin = vi.fn(); const verifySession = vi.fn(); vi.mock("@/lib/admin-auth", () => ({ getAdmin, hasAdmin: async () => false })); vi.mock("@/lib/session", () => ({ verifySession, authState: async () => "signed-out" })); vi.mock("@/lib/setup-code", () => ({ ensureSetupCode: () => "CODE" })); const actions = vi.hoisted(() => ({ login: vi.fn(), setupAdmin: vi.fn() })); vi.mock("./auth-actions", () => ({ login: actions.login, setupAdmin: actions.setupAdmin, logout: async () => {}, skipSetup: async () => {}, })); const { default: CameraCard } = await import("./camera-card"); const { default: CameraScanner } = await import("./camera-scanner"); const { default: RefreshRateSelect } = await import("./refresh-rate-select"); const { default: SecurityBar } = await import("./security-bar"); const { default: LoginPage } = await import("./login/page"); const { default: SetupPage } = await import("./setup/page"); const { default: LoginForm } = await import("./login/login-form"); const { default: SetupForm } = await import("./setup/setup-form"); /** * The Lucide icon names on the first icon inside an element, e.g. ["key-round"]. Lucide also * adds classes for older aliases (Trash2 → "trash", "trash-2"), so compare with toContain. */ const iconIn = (el: Element) => [...(el.querySelector("svg")?.getAttribute("class") ?? "").matchAll(/lucide-([a-z0-9-]+)/g)].map( (m) => m[1], ); const allIconsDecorative = (root: ParentNode = document) => { const svgs = [...root.querySelectorAll("svg.lucide")]; expect(svgs.length).toBeGreaterThan(0); for (const svg of svgs) expect(svg.getAttribute("aria-hidden")).toBe("true"); }; const cam: CameraSummary = { id: "cam1", host: "192.168.1.10", port: 80, name: "Porch", lastSeen: "" }; const base = "/api/cameras/cam1"; const info = { manufacturer: "Hikvision", model: "DS", firmwareVersion: "1", profiles: [] }; beforeEach(() => { vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); }); describe("camera card", () => { it("marks the settings icon, the only icon control left on a card", async () => { stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: () => new Response(new Blob(["x"])), }); renderWithQuery(); // Icon-only, so its name has to say what it does and to which camera (pri-8ev5m89). const settings = await screen.findByRole("link", { name: "Settings for Porch" }); expect(iconIn(settings)).toContain("pencil"); allIconsDecorative(settings); }); it("marks the login form: title, Save, Forget, and errors", async () => { stubFetch({ [`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401), [`GET ${base}/credentials`]: () => json({ source: "stored", username: "admin", hasPassword: true }), [`PUT ${base}/credentials`]: () => json({ error: "nope", code: "auth" }, 401), }); renderWithQuery(); expect(iconIn((await screen.findByText("Camera login")).closest("div")!)).toContain("key-round"); expect(iconIn(screen.getByRole("button", { name: "Save" }))).toContain("lock"); expect(iconIn(await screen.findByRole("button", { name: "Forget saved login" }))).toContain("trash-2"); fireEvent.click(screen.getByRole("button", { name: "Save" })); const error = await screen.findByText("Camera rejected that login."); expect(iconIn(error)).toContain("circle-alert"); allIconsDecorative(); }); it("marks the setup panel and its Check again button", async () => { stubFetch({ [`GET ${base}/info`]: () => json({ error: "x", code: "inactive" }, 409) }); renderWithQuery(); const title = await screen.findByText("Setup required: camera not activated"); expect(iconIn(title)).toContain("triangle-alert"); expect(iconIn(screen.getByRole("button", { name: "Check again" }))).toContain("refresh-cw"); allIconsDecorative(); }); it("marks the stopped error and Retry", async () => { stubFetch({ [`GET ${base}/info`]: () => json({ error: "Camera request failed" }, 502) }); renderWithQuery(); expect(iconIn(await screen.findByText(/^Stopped:/))).toContain("circle-alert"); expect(iconIn(screen.getByRole("button", { name: "Retry" }))).toContain("refresh-cw"); }); it("marks the session-ended notice and its sign-in link", async () => { stubFetch({ [`GET ${base}/info`]: () => json({ error: "Sign in required" }, 401) }); renderWithQuery(); expect(iconIn(await screen.findByText("Your session has ended."))).toContain("circle-alert"); expect(iconIn(screen.getByRole("link", { name: "Sign in again" }))).toContain("log-in"); }); }); describe("dashboard controls", () => { it("Scan network shows a radar, then a spinner while scanning", async () => { let finish!: (r: Response) => void; stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) }); renderWithQuery(); const scan = screen.getByRole("button", { name: /Scan network/ }); expect(iconIn(scan)).toContain("radar"); fireEvent.click(scan); const busy = await screen.findByRole("button", { name: /Scanning…/ }); expect(iconIn(busy)).toContain("loader-circle"); expect(busy.querySelector("svg")!.getAttribute("class")).toContain("motion-reduce:animate-none"); finish(json({ error: "Network scan failed" }, 500)); expect(iconIn(await screen.findByText(/^Error:/))).toContain("circle-alert"); allIconsDecorative(); }); it("the refresh selector has a timer and keeps its label", () => { renderWithQuery(); const select = screen.getByRole("combobox", { name: /Snapshot refresh/ }); expect(iconIn(select.closest("label")!)).toContain("timer"); }); }); describe("security bar and auth pages", () => { const markup = async (node: Promise | React.ReactNode) => { const div = document.createElement("div"); div.innerHTML = renderToStaticMarkup(<>{await node}); return div; }; it("the unsecured banner carries a shield alert", async () => { getAdmin.mockResolvedValue(null); const bar = await markup(SecurityBar()); expect(iconIn(bar.querySelector('[role="alert"] p')!)).toContain("shield-alert"); allIconsDecorative(bar); }); it("the signed-in bar has a shield check and Sign out has a log-out icon", async () => { getAdmin.mockResolvedValue({ username: "admin" }); verifySession.mockResolvedValue({ username: "admin", expiresAt: 0 }); const bar = await markup(SecurityBar()); expect(iconIn(bar)).toContain("shield-check"); expect(iconIn(bar.querySelector("button")!)).toContain("log-out"); allIconsDecorative(bar); }); it("/login has a lock heading and a sign-in button icon", async () => { const page = await markup( LoginPage({ params: Promise.resolve({}), searchParams: Promise.resolve({}) }), ); expect(iconIn(page.querySelector("h1")!)).toContain("lock"); expect(iconIn(page.querySelector('button[type="submit"]')!)).toContain("log-in"); allIconsDecorative(page); }); it("/setup has a shield-check heading and a shield alert on the skip warning", async () => { const page = await markup(SetupPage()); expect(iconIn(page.querySelector("h1")!)).toContain("shield-check"); const warning = [...page.querySelectorAll("p")].find((p) => p.textContent === "Skip at your own risk")!; expect(iconIn(warning)).toContain("shield-alert"); allIconsDecorative(page); }); it.each([ ["login", () => , actions.login, "Sign in"], ["setup", () => , actions.setupAdmin, "Create admin and sign in"], ] as const)("the %s form marks its error with a circle alert", async (_n, Form, action, submit) => { action.mockResolvedValue({ error: "Something went wrong." }); renderWithQuery(
); for (const input of document.querySelectorAll("input[required]")) { fireEvent.change(input, { target: { value: "correct horse battery" } }); } fireEvent.click(screen.getByRole("button", { name: submit })); await waitFor(() => expect(iconIn(screen.getByRole("alert"))).toContain("circle-alert")); allIconsDecorative(); }); });