The snapshot is now the way into the live pop-out, as a link, so keyboard and a blocked popup both still work. The icon over the top right is the camera's own page instead: a pencil, named "Settings for <camera>". The pop-out icon is gone, so a card carries one icon, not two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
9.1 KiB
TypeScript
205 lines
9.1 KiB
TypeScript
// @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(<CameraCard cam={cam} intervalMs={60_000} />);
|
|
|
|
// 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(<CameraCard cam={cam} intervalMs={60_000} />);
|
|
|
|
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(<CameraCard cam={cam} intervalMs={60_000} />);
|
|
|
|
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(<CameraCard cam={cam} intervalMs={60_000} />);
|
|
|
|
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(<CameraCard cam={cam} intervalMs={60_000} />);
|
|
|
|
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(<CameraScanner />);
|
|
|
|
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(<RefreshRateSelect value={1000} />);
|
|
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> | 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", () => <LoginForm next="/" />, actions.login, "Sign in"],
|
|
["setup", () => <SetupForm />, 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(<Form />);
|
|
for (const input of document.querySelectorAll<HTMLInputElement>("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();
|
|
});
|
|
});
|