Improve frontend e2e tests (#22958)

* add mock data

* add helpers

* page objects

* updated specs

* remove PENDING_REWARITE

* formatting
This commit is contained in:
Josh Hawkins
2026-04-21 17:32:18 -05:00
committed by GitHub
parent 3b81416299
commit 962d36323b
24 changed files with 3030 additions and 1414 deletions
@@ -0,0 +1,54 @@
/**
* Debug replay status factory.
*
* The Replay page polls /api/debug_replay/status every 1s via SWR.
* The no-session state shows an empty state; the active state
* renders the live camera image + debug toggles + objects/messages
* tabs. Used by replay.spec.ts.
*/
export type DebugReplayStatus = {
active: boolean;
replay_camera: string | null;
source_camera: string | null;
start_time: number | null;
end_time: number | null;
live_ready: boolean;
};
export function noSessionStatus(): DebugReplayStatus {
return {
active: false,
replay_camera: null,
source_camera: null,
start_time: null,
end_time: null,
live_ready: false,
};
}
export function activeSessionStatus(
opts: {
camera?: string;
sourceCamera?: string;
startTime?: number;
endTime?: number;
liveReady?: boolean;
} = {},
): DebugReplayStatus {
const {
camera = "front_door",
sourceCamera = "front_door",
startTime = Date.now() / 1000 - 3600,
endTime = Date.now() / 1000 - 1800,
liveReady = true,
} = opts;
return {
active: true,
replay_camera: camera,
source_camera: sourceCamera,
start_time: startTime,
end_time: endTime,
live_ready: liveReady,
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Face library factories.
*
* The /api/faces endpoint returns a record keyed by collection name
* with the list of face image filenames. Grouped training attempts
* live under the "train" key with filenames of the form
* `${event_id}-${timestamp}-${label}-${score}.webp`.
*
* Used by face-library.spec.ts and chat.spec.ts (attachment chip).
*/
export type FacesMock = Record<string, string[]>;
export function basicFacesMock(): FacesMock {
return {
alice: ["alice-1.webp", "alice-2.webp"],
bob: ["bob-1.webp"],
charlie: ["charlie-1.webp"],
};
}
export function emptyFacesMock(): FacesMock {
return {};
}
/**
* Adds a grouped recent-recognition training attempt to an existing
* faces mock. The grouping key on the backend is the event id — so
* images with the same event-id prefix render as one dialog-able card.
*/
export function withGroupedTrainingAttempt(
base: FacesMock,
opts: {
eventId: string;
attempts: Array<{ timestamp: number; label: string; score: number }>;
},
): FacesMock {
const trainImages = opts.attempts.map(
(a) => `${opts.eventId}-${a.timestamp}-${a.label}-${a.score}.webp`,
);
return {
...base,
train: [...(base.train ?? []), ...trainImages],
};
}
+4 -3
View File
@@ -113,11 +113,12 @@ export class ApiMocker {
route.fulfill({ json: [] }),
);
// Sub-labels and attributes (for explore filters)
await this.page.route("**/api/sub_labels", (route) =>
// Sub-labels and attributes (for explore filters).
// Use trailing ** so query-string variants (e.g. ?split_joined=1) match.
await this.page.route("**/api/sub_labels**", (route) =>
route.fulfill({ json: [] }),
);
await this.page.route("**/api/labels", (route) =>
await this.page.route("**/api/labels**", (route) =>
route.fulfill({ json: ["person", "car"] }),
);
await this.page.route("**/api/*/attributes", (route) =>
+25
View File
@@ -0,0 +1,25 @@
/**
* Clipboard read helper for e2e tests.
*
* Clipboard API requires a browser permission in headless mode.
* grantClipboardPermissions() must be called before any readClipboard()
* attempt. Used by logs.spec.ts (Copy button) and config-editor.spec.ts
* (Copy button).
*/
import type { BrowserContext, Page } from "@playwright/test";
/**
* Grant clipboard-read + clipboard-write permissions on the context.
* Call in beforeEach or at the top of a test before the Copy action.
*/
export async function grantClipboardPermissions(
context: BrowserContext,
): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
/** Read the current clipboard contents via the page's navigator.clipboard. */
export async function readClipboard(page: Page): Promise<string> {
return page.evaluate(async () => await navigator.clipboard.readText());
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Monaco editor DOM helpers for e2e tests.
*
* Monaco is imported as a module-local object in the app and is NOT
* exposed on window; we drive + read through the rendered DOM and
* keyboard instead. Used by config-editor.spec.ts only.
*/
import { expect, type Page } from "@playwright/test";
/**
* Returns the current visible text of the first Monaco editor on the
* page. Monaco virtualizes long files — this reads only the rendered
* lines. For short configs (our mocks) that's the full content.
*/
export async function getMonacoVisibleText(page: Page): Promise<string> {
return page.locator(".monaco-editor .view-lines").first().innerText();
}
/**
* Focus the editor and replace its full content with `value` via
* keyboard. Uses Ctrl+A (Cmd+A on macOS Playwright is equivalent)
* + Delete + type. Works cross-platform because Playwright normalizes.
*/
export async function replaceMonacoValue(
page: Page,
value: string,
): Promise<void> {
const editor = page.locator(".monaco-editor").first();
await editor.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("Delete");
// Use `type` with zero delay — Monaco handles each key.
await page.keyboard.type(value, { delay: 0 });
}
/**
* Returns true when the editor shows at least one error-severity
* marker. Monaco renders error underlines as `.squiggly-error` in
* the `.view-overlays` layer.
*/
export async function hasErrorMarkers(page: Page): Promise<boolean> {
const count = await page.locator(".monaco-editor .squiggly-error").count();
return count > 0;
}
/**
* Poll until an error marker appears. Monaco schedules marker updates
* asynchronously after content changes (debounce + schema validation).
*/
export async function waitForErrorMarker(
page: Page,
timeoutMs: number = 10_000,
): Promise<void> {
await expect
.poll(() => hasErrorMarkers(page), { timeout: timeoutMs })
.toBe(true);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* WebSocket frame capture helper.
*
* The ws-mocker intercepts the /ws route, so Playwright's page-level
* `websocket` event never fires. This helper patches client-side
* WebSocket.prototype.send before any app code runs and mirrors every
* sent frame into a window-level array the test can read back.
*
* Used by live.spec.ts (feature toggles, PTZ preset commands) and
* config-editor.spec.ts (restart command via useRestart).
*/
import { expect, type Page } from "@playwright/test";
export type CapturedFrame = string;
declare global {
interface Window {
__sentWsFrames: CapturedFrame[];
}
}
/**
* Patch WebSocket.prototype.send to capture every outbound frame into
* window.__sentWsFrames. Must be called BEFORE page.goto().
*/
export async function installWsFrameCapture(page: Page): Promise<void> {
await page.addInitScript(() => {
window.__sentWsFrames = [];
const origSend = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
try {
window.__sentWsFrames.push(
typeof data === "string" ? data : "(binary)",
);
} catch {
// ignore — best-effort tracing
}
return origSend.call(this, data);
};
});
}
/** Read all captured frames at call time. */
export async function readWsFrames(page: Page): Promise<CapturedFrame[]> {
return page.evaluate(() => window.__sentWsFrames ?? []);
}
/**
* Poll until at least one captured frame matches the predicate.
* Throws via expect if the frame never arrives within timeout.
*/
export async function waitForWsFrame(
page: Page,
matcher: (frame: CapturedFrame) => boolean,
opts: { timeout?: number; message?: string } = {},
): Promise<void> {
const { timeout = 2_000, message } = opts;
await expect
.poll(async () => (await readWsFrames(page)).some(matcher), {
timeout,
message,
})
.toBe(true);
}
+14 -1
View File
@@ -79,7 +79,20 @@ export class WsMocker {
this.send("model_state", JSON.stringify({}));
}
if (data.topic === "embeddingsReindexProgress") {
this.send("embeddings_reindex_progress", JSON.stringify(null));
// Send a completed reindex state so Explore renders when
// semantic_search.enabled is true. A null payload leaves the page
// in a permanent loading spinner because !reindexState is truthy.
this.send(
"embeddings_reindex_progress",
JSON.stringify({
status: "completed",
processed_objects: 0,
total_objects: 0,
thumbnails: 0,
descriptions: 0,
time_remaining: null,
}),
);
}
if (data.topic === "birdseyeLayout") {
this.send("birdseye_layout", JSON.stringify(null));
+55
View File
@@ -0,0 +1,55 @@
/**
* Live dashboard + single-camera page object.
*
* Encapsulates selectors and viewport-conditional openers for the
* Live route. Does NOT own assertions — specs call expect on the
* locators returned from these getters.
*/
import type { Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page";
export class LivePage extends BasePage {
constructor(page: Page, isDesktop: boolean) {
super(page, isDesktop);
}
/** The camera card wrapper on the dashboard, keyed by camera name. */
cameraCard(name: string): Locator {
return this.page.locator(`[data-camera='${name}']`);
}
/** Back button on the single-camera view header (desktop text). */
get backButton(): Locator {
return this.page.getByText("Back", { exact: true });
}
/** History button on the single-camera view header (desktop text). */
get historyButton(): Locator {
return this.page.getByText("History", { exact: true });
}
/** All CameraFeatureToggle elements (active + inactive). */
get featureToggles(): Locator {
// Use div selector to exclude NavItem anchor elements that share the same classes.
return this.page.locator(
"div.flex.flex-col.items-center.justify-center.bg-selected, div.flex.flex-col.items-center.justify-center.bg-secondary",
);
}
/** Only the active (bg-selected) feature toggles. */
get activeFeatureToggles(): Locator {
// Use div selector to exclude NavItem anchor elements that share the same classes.
return this.page.locator(
"div.flex.flex-col.items-center.justify-center.bg-selected",
);
}
/** Open the right-click context menu on a camera card (desktop only). */
async openContextMenuOn(cameraName: string): Promise<Locator> {
await this.cameraCard(cameraName).first().click({ button: "right" });
return this.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Review/events page object.
*
* Encapsulates severity tab, filter bar, calendar, and mobile filter
* drawer selectors. Does NOT own assertions.
*/
import type { Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page";
export class ReviewPage extends BasePage {
constructor(page: Page, isDesktop: boolean) {
super(page, isDesktop);
}
get alertsTab(): Locator {
return this.page.getByLabel("Alerts");
}
get detectionsTab(): Locator {
return this.page.getByLabel("Detections");
}
get motionTab(): Locator {
return this.page.getByRole("radio", { name: "Motion" });
}
get camerasFilterTrigger(): Locator {
return this.page.getByRole("button", { name: /cameras/i }).first();
}
get calendarTrigger(): Locator {
return this.page.getByRole("button", { name: /24 hours|calendar|date/i });
}
get showReviewedToggle(): Locator {
return this.page.getByRole("button", { name: /reviewed/i });
}
get reviewItems(): Locator {
return this.page.locator(".review-item");
}
/** The filter popover content (desktop) or drawer (mobile). */
get filterOverlay(): Locator {
return this.page
.locator(
'[data-radix-popper-content-wrapper], [role="dialog"], [data-vaul-drawer]',
)
.first();
}
}
+2 -28
View File
@@ -14,10 +14,6 @@
*
* @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must
* contain at least one test title or describe with the substring "@mobile".
*
* Specs in PENDING_REWRITE are exempt from all rules until they are
* rewritten with proper assertions and mobile coverage. Remove each
* entry when its spec is updated.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
@@ -28,24 +24,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const SPECS_DIR = resolve(__dirname, "..", "specs");
const META_PREFIX = resolve(SPECS_DIR, "_meta");
// Specs exempt from lint rules until they are rewritten with proper
// assertions and mobile coverage. Remove each entry when its spec is updated.
const PENDING_REWRITE = new Set([
"auth.spec.ts",
"chat.spec.ts",
"classification.spec.ts",
"config-editor.spec.ts",
"explore.spec.ts",
"export.spec.ts",
"face-library.spec.ts",
"live.spec.ts",
"logs.spec.ts",
"navigation.spec.ts",
"replay.spec.ts",
"review.spec.ts",
"system.spec.ts",
]);
const BANNED_PATTERNS = [
{
name: "page.waitForTimeout",
@@ -62,14 +40,12 @@ const BANNED_PATTERNS = [
{
name: "conditional count() assertion",
regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/,
advice:
"Assertions must be unconditional. Use expect(...).toHaveCount(n).",
advice: "Assertions must be unconditional. Use expect(...).toHaveCount(n).",
},
{
name: "vacuous textContent length assertion",
regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/,
advice:
"Assert specific content, not that some text exists.",
advice: "Assert specific content, not that some text exists.",
},
];
@@ -89,8 +65,6 @@ function walk(dir) {
}
function lintFile(file) {
const basename = file.split("/").pop();
if (PENDING_REWRITE.has(basename)) return [];
if (file.includes("/specs/settings/")) return [];
const errors = [];
+64 -101
View File
@@ -1,147 +1,110 @@
/**
* Auth and cross-cutting tests -- HIGH tier.
* Auth and role tests -- HIGH tier.
*
* Tests protected route access for admin/viewer roles,
* access denied page rendering, viewer nav restrictions,
* and all routes smoke test.
* Admin access to /system, /config, /logs; viewer access denied
* markers (via i18n heading, not a data-testid we don't own);
* viewer nav restrictions; all-routes smoke.
*/
import { test, expect } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth - Admin Access @high", () => {
test("admin can access /system and sees system tabs", async ({
frigateApp,
}) => {
test.describe("Auth — admin access @high", () => {
test("admin /system renders general tab", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await frigateApp.page.waitForTimeout(3000);
// System page should have named tab buttons
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 5_000,
timeout: 15_000,
});
});
test("admin can access /config and Monaco editor loads", async ({
frigateApp,
}) => {
test("admin /config renders Monaco editor", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
const editor = frigateApp.page.locator(
".monaco-editor, [data-keybinding-context]",
);
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
await expect(
frigateApp.page
.locator(".monaco-editor, [data-keybinding-context]")
.first(),
).toBeVisible({ timeout: 15_000 });
});
test("admin can access /logs and sees service tabs", async ({
frigateApp,
}) => {
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
});
test("admin sees Classification nav on desktop", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
test.describe("Auth — viewer restrictions @high", () => {
for (const path of ["/system", "/config", "/logs"]) {
test(`viewer on ${path} sees AccessDenied`, async ({ frigateApp }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await frigateApp.page.goto(path);
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(
frigateApp.page.locator('a[href="/classification"]'),
).toBeVisible();
frigateApp.page.getByRole("heading", {
level: 2,
name: /access denied/i,
}),
).toBeVisible({ timeout: 10_000 });
});
}
test("viewer sees cameras on /", async ({ frigateApp }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await frigateApp.page.goto("/");
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test.describe("Auth - Viewer Restrictions @high", () => {
test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => {
test("viewer sees severity tabs on /review", async ({ frigateApp }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/system");
await page.waitForTimeout(2000);
// Should show "Access Denied" text
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer sees Access Denied on /config", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/config");
await page.waitForTimeout(2000);
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer sees Access Denied on /logs", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/logs");
await page.waitForTimeout(2000);
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer can access Live page and sees cameras", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/");
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(page.locator("[data-camera='front_door']")).toBeVisible({
await frigateApp.page.goto("/review");
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
});
});
test("viewer can access Review page and sees severity tabs", async ({
test("viewer can access all non-admin routes without AccessDenied", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/review");
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(page.getByLabel("Alerts")).toBeVisible({ timeout: 5_000 });
});
test("viewer can access all main user routes without crash", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
const routes = ["/", "/review", "/explore", "/export", "/settings"];
for (const route of routes) {
await page.goto(route);
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
await frigateApp.page.goto(route);
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /access denied/i,
}),
).toHaveCount(0);
}
});
});
test.describe("Auth - All Routes Smoke @high", () => {
test("all user routes render without crash", async ({ frigateApp }) => {
const routes = ["/", "/review", "/explore", "/export", "/settings"];
for (const route of routes) {
test.describe("Auth — viewer nav restrictions (desktop) @high", () => {
test.skip(({ frigateApp }) => frigateApp.isMobile, "Sidebar only on desktop");
test("viewer sidebar hides admin routes", async ({ frigateApp }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await frigateApp.page.goto("/");
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
for (const href of ["/system", "/config", "/logs"]) {
await expect(
frigateApp.page.locator(`aside a[href='${href}']`),
).toHaveCount(0);
}
});
});
test.describe("Auth — all routes smoke @high @mobile", () => {
test("every common route renders #pageRoot", async ({ frigateApp }) => {
for (const route of ["/", "/review", "/explore", "/export", "/settings"]) {
await frigateApp.goto(route);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
}
});
test("admin routes render with specific content", async ({ frigateApp }) => {
// System page should have tab controls
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(3000);
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 5_000,
});
// Logs page should have service tabs
await frigateApp.goto("/logs");
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
});
+301 -24
View File
@@ -1,34 +1,311 @@
/**
* Chat page tests -- MEDIUM tier.
*
* Tests chat interface rendering, input area, and example prompt buttons.
* Starting state, NDJSON streaming contract (not SSE), assistant
* bubble grows as chunks arrive, error path, and mobile viewport.
*/
import { test, expect } from "../fixtures/frigate-test";
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
test.describe("Chat Page @medium", () => {
test("chat page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
/**
* Install a window.fetch override on the page so that POSTs to
* chat/completion resolve with a real ReadableStream that emits the
* given chunks over time. This is the only way to validate
* chunk-by-chunk rendering through Playwright — page.route() does not
* support streaming responses.
*
* Must be called BEFORE frigateApp.goto(). The override also exposes
* `__chatRequests` on window so tests can assert the outgoing body.
*/
async function installChatStreamOverride(
app: FrigateApp,
chunks: Array<Record<string, unknown>>,
opts: { chunkDelayMs?: number; status?: number } = {},
) {
const { chunkDelayMs = 40, status = 200 } = opts;
await app.page.addInitScript(
({ chunks, chunkDelayMs, status }) => {
(window as unknown as { __chatRequests: unknown[] }).__chatRequests = [];
const origFetch = window.fetch;
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
if (url.includes("chat/completion")) {
const body =
init?.body instanceof String || typeof init?.body === "string"
? JSON.parse(init!.body as string)
: null;
(
window as unknown as { __chatRequests: unknown[] }
).__chatRequests.push({ url, body });
if (status !== 200) {
return new Response(JSON.stringify({ error: "boom" }), {
status,
});
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
const interactive = frigateApp.page.locator("input, textarea, button");
const count = await interactive.count();
expect(count).toBeGreaterThan(0);
});
test("chat input accepts text", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
const input = frigateApp.page.locator("input, textarea").first();
if (await input.isVisible().catch(() => false)) {
await input.fill("What cameras detected a person today?");
const value = await input.inputValue();
expect(value.length).toBeGreaterThan(0);
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const chunk of chunks) {
await new Promise((r) => setTimeout(r, chunkDelayMs));
controller.enqueue(
encoder.encode(JSON.stringify(chunk) + "\n"),
);
}
controller.close();
},
});
return new Response(stream, { status: 200 });
}
return origFetch.call(window, input as RequestInfo, init);
};
},
{ chunks, chunkDelayMs, status },
);
}
test.describe("Chat — starting state @medium", () => {
test("empty message list renders ChatStartingState with title and input", async ({
frigateApp,
}) => {
await frigateApp.goto("/chat");
await expect(
frigateApp.page.getByRole("heading", { level: 1 }),
).toBeVisible({ timeout: 10_000 });
await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible();
// Four quick-reply buttons from starting_requests.*
const quickReplies = frigateApp.page.locator(
"button:has-text('Show recent events'), button:has-text('Show camera status'), button:has-text('What happened'), button:has-text('Watch')",
);
await expect(quickReplies.first()).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Chat — streaming @medium", () => {
test("submission POSTs to chat/completion with stream: true", async ({
frigateApp,
}) => {
await installChatStreamOverride(frigateApp, [
{ type: "content", delta: "Hel" },
{ type: "content", delta: "lo" },
]);
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("hello chat");
await input.press("Enter");
await expect
.poll(
async () =>
frigateApp.page.evaluate(
() =>
(window as unknown as { __chatRequests: unknown[] })
.__chatRequests?.length ?? 0,
),
{ timeout: 5_000 },
)
.toBeGreaterThan(0);
const request = await frigateApp.page.evaluate(
() =>
(
window as unknown as {
__chatRequests: Array<{
url: string;
body: { stream: boolean; messages: Array<{ content: string }> };
}>;
}
).__chatRequests[0],
);
expect(request.body.stream).toBe(true);
expect(
request.body.messages[request.body.messages.length - 1].content,
).toBe("hello chat");
});
test("NDJSON content chunks accumulate in the assistant bubble", async ({
frigateApp,
}) => {
await installChatStreamOverride(
frigateApp,
[
{ type: "content", delta: "Hel" },
{ type: "content", delta: "lo, " },
{ type: "content", delta: "world!" },
],
{ chunkDelayMs: 50 },
);
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("greet me");
await input.press("Enter");
await expect(frigateApp.page.getByText(/Hello, world!/i)).toBeVisible({
timeout: 10_000,
});
});
test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => {
await installChatStreamOverride(frigateApp, [
{
type: "tool_calls",
tool_calls: [
{
id: "call_1",
name: "search_objects",
arguments: { label: "person" },
},
],
},
{ type: "content", delta: "Searching for people." },
]);
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("find people");
await input.press("Enter");
// ToolCallsGroup normalizes "search_objects" → "Search Objects" via
// normalizeName(). Match the rendered display label instead.
await expect(frigateApp.page.getByText(/search objects/i)).toBeVisible({
timeout: 10_000,
});
await expect(
frigateApp.page.getByText(/searching for people/i),
).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Chat — stop @medium", () => {
test("Stop button aborts an in-flight stream and freezes the partial message", async ({
frigateApp,
}) => {
// A long chunk sequence with big delays gives us time to hit Stop.
await installChatStreamOverride(
frigateApp,
[
{ type: "content", delta: "First chunk. " },
{ type: "content", delta: "Second chunk. " },
{ type: "content", delta: "Third chunk. " },
],
{ chunkDelayMs: 300 },
);
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("slow response please");
await input.press("Enter");
// Wait for the first chunk to render
await expect(frigateApp.page.getByText(/First chunk\./)).toBeVisible({
timeout: 10_000,
});
// The Stop button is a destructive rounded button shown while isLoading.
// It contains only an FaStop SVG icon (no visible text). Find it by the
// destructive variant class or fall back to aria-label.
const stopBtn = frigateApp.page
.locator("button.bg-destructive, button[class*='destructive']")
.first();
await stopBtn.click({ timeout: 3_000 }).catch(async () => {
await frigateApp.page
.getByRole("button", { name: /stop|cancel/i })
.first()
.click();
});
// Third chunk should never appear.
await expect(frigateApp.page.getByText(/Third chunk\./)).toHaveCount(0);
});
});
test.describe("Chat — error @medium", () => {
test("non-OK response renders an error banner", async ({ frigateApp }) => {
await installChatStreamOverride(frigateApp, [], { status: 500 });
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("trigger error");
await input.press("Enter");
// The error banner is a role="alert" paragraph; target by role so we
// don't collide with the user-message bubble that contains "trigger
// error" (which would match /error/ in strict mode).
await expect(
frigateApp.page.getByRole("alert").filter({
hasText: /boom|something went wrong/i,
}),
).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Chat — attachment chip @medium", () => {
test("attaching an event renders a ChatAttachmentChip", async ({
frigateApp,
}) => {
// The chat starts with an empty message list (ChatStartingState).
// After sending a message, ChatEntry with the paperclip button appears.
// We use the stream override so the first message completes quickly.
await installChatStreamOverride(frigateApp, [
{ type: "content", delta: "Done." },
]);
await frigateApp.goto("/chat");
// Send a first message to transition out of ChatStartingState so the
// full ChatEntry (with the paperclip) is visible.
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("hello");
await input.press("Enter");
// Wait for the assistant response to complete so isLoading becomes false
// and the paperclip button is re-enabled.
await expect(frigateApp.page.getByText(/Done\./i)).toBeVisible({
timeout: 10_000,
});
// The paperclip button has aria-label from t("attachment_picker_placeholder")
// = "Attach an event".
const paperclip = frigateApp.page
.getByRole("button", { name: /attach an event/i })
.first();
await expect(paperclip).toBeVisible({ timeout: 5_000 });
await paperclip.click();
// The popover shows a paste input with placeholder "Or paste event ID".
const idInput = frigateApp.page
.locator('input[placeholder*="event" i], input[aria-label*="attach" i]')
.first();
await expect(idInput).toBeVisible({ timeout: 3_000 });
await idInput.fill("test-event-1");
await frigateApp.page
.getByRole("button", { name: /^attach$/i })
.first()
.click();
// The ChatAttachmentChip renders in the composer area. It shows an
// activity indicator while loading event data (event_ids API not mocked),
// so assert on the chip container being present in the composer.
await expect(
frigateApp.page.locator(
"[class*='inline-flex'][class*='rounded-lg'][class*='border']",
),
).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Chat — mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("chat input is focusable at mobile viewport", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.focus();
await expect(input).toBeFocused();
});
});
+209 -14
View File
@@ -1,33 +1,228 @@
/**
* Classification page tests -- MEDIUM tier.
*
* Tests model selection view rendering and interactive elements.
* Model list driven by config.classification.custom + per-model
* dataset fetches. Admin-only access.
*/
import { test, expect } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Classification @medium", () => {
test("classification page renders without crash", async ({ frigateApp }) => {
const CUSTOM_MODELS = {
object_classifier: {
name: "object_classifier",
object_config: { objects: ["person"], classification_type: "sub_label" },
},
state_classifier: {
name: "state_classifier",
state_config: { cameras: { front_door: { crop: [0, 0, 1, 1] } } },
},
};
async function installDatasetRoute(
app: { page: import("@playwright/test").Page },
name: string,
body: Record<string, unknown> = { categories: {} },
) {
await app.page.route(
new RegExp(`/api/classification/${name}/dataset`),
(route) => route.fulfill({ json: body }),
);
}
async function installTrainRoute(
app: { page: import("@playwright/test").Page },
name: string,
) {
await app.page.route(
new RegExp(`/api/classification/${name}/train`),
(route) => route.fulfill({ json: [] }),
);
}
test.describe("Classification — model list @medium", () => {
test("custom models render by name", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: { classification: { custom: CUSTOM_MODELS } },
});
await installDatasetRoute(frigateApp, "object_classifier");
await installDatasetRoute(frigateApp, "state_classifier");
await frigateApp.goto("/classification");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
timeout: 10_000,
});
});
test("classification page shows content and controls", async ({
frigateApp,
}) => {
test("empty custom map renders without crash", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: { classification: { custom: {} } },
});
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
});
test("classification page has interactive elements", async ({
test("toggling to states view switches the rendered card set", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { classification: { custom: CUSTOM_MODELS } },
});
await installDatasetRoute(frigateApp, "object_classifier");
await installDatasetRoute(frigateApp, "state_classifier");
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
// Objects is default — object_classifier visible, state_classifier hidden.
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
timeout: 10_000,
});
await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0);
// Click the "states" toggle. Radix ToggleGroup type="single" uses role="radio".
const statesToggle = frigateApp.page
.getByRole("radio", { name: /state/i })
.first();
await expect(statesToggle).toBeVisible({ timeout: 5_000 });
await statesToggle.click();
await expect(frigateApp.page.getByText("state_classifier")).toBeVisible({
timeout: 5_000,
});
await expect(frigateApp.page.getByText("object_classifier")).toHaveCount(0);
});
});
test.describe("Classification — model detail navigation @medium", () => {
test("clicking a model card opens ModelTrainingView", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { classification: { custom: CUSTOM_MODELS } },
});
await installDatasetRoute(frigateApp, "object_classifier");
await installDatasetRoute(frigateApp, "state_classifier");
await installTrainRoute(frigateApp, "object_classifier");
await frigateApp.goto("/classification");
const objectCard = frigateApp.page.getByText("object_classifier").first();
await expect(objectCard).toBeVisible({ timeout: 10_000 });
await objectCard.click();
// ModelTrainingView renders a Back button (aria-label "Back").
// useOverlayState stores the selected model in window.history.state
// (not the URL), so we verify the state transition via the DOM.
await expect(
frigateApp.page.getByRole("button", { name: /back/i }),
).toBeVisible({ timeout: 5_000 });
// The model grid is no longer shown; state_classifier card is gone.
await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0);
});
});
test.describe("Classification — delete model (desktop) @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Delete action menu is desktop-focused",
);
test("deleting a model fires DELETE + PUT /config/set", async ({
frigateApp,
}) => {
let deleteCalled = false;
let configSetCalled = false;
// installDefaults must run first because Playwright matches routes in
// LIFO order — routes registered after installDefaults take precedence
// over the generic catch-all registered inside it.
await frigateApp.installDefaults({
config: { classification: { custom: CUSTOM_MODELS } },
});
await installDatasetRoute(frigateApp, "object_classifier");
await installDatasetRoute(frigateApp, "state_classifier");
// Register spy routes after installDefaults so they win over the catch-all.
await frigateApp.page.route(
/\/api\/classification\/object_classifier$/,
async (route) => {
if (route.request().method() === "DELETE") {
deleteCalled = true;
await route.fulfill({ json: { success: true } });
return;
}
return route.fallback();
},
);
await frigateApp.page.route("**/api/config/set", async (route) => {
if (route.request().method() === "PUT") configSetCalled = true;
await route.fulfill({ json: { success: true, require_restart: false } });
});
await frigateApp.goto("/classification");
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
timeout: 10_000,
});
// The card-level actions menu (FiMoreVertical three-dot icon) is a
// DropdownMenuTrigger with asChild on a BlurredIconButton div.
// Radix forwards aria-haspopup="menu" to the child element.
// Scope the selector to the model card grid to avoid hitting the
// settings sidebar trigger.
const cardGrid = frigateApp.page.locator(".grid.auto-rows-max");
await expect(cardGrid).toBeVisible({ timeout: 5_000 });
const trigger = cardGrid.locator('[aria-haspopup="menu"]').first();
await expect(trigger).toBeVisible({ timeout: 5_000 });
await trigger.click();
const deleteItem = frigateApp.page
.getByRole("menuitem", { name: /delete/i })
.first();
await expect(deleteItem).toBeVisible({ timeout: 5_000 });
await deleteItem.click();
// Confirm the AlertDialog.
const alert = frigateApp.page.getByRole("alertdialog");
await expect(alert).toBeVisible({ timeout: 5_000 });
await alert
.getByRole("button", { name: /delete|confirm/i })
.first()
.click();
await expect.poll(() => deleteCalled, { timeout: 5_000 }).toBe(true);
await expect.poll(() => configSetCalled, { timeout: 5_000 }).toBe(true);
});
});
test.describe("Classification — admin only @medium", () => {
test("viewer navigating to /classification is redirected to access-denied", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await frigateApp.page.goto("/classification");
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/\/unauthorized/, {
timeout: 10_000,
});
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /access denied/i,
}),
).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Classification — mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("page renders at mobile viewport", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: { classification: { custom: CUSTOM_MODELS } },
});
await installDatasetRoute(frigateApp, "object_classifier");
await installDatasetRoute(frigateApp, "state_classifier");
await frigateApp.goto("/classification");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
});
});
+258 -26
View File
@@ -1,44 +1,276 @@
/**
* Config Editor page tests -- MEDIUM tier.
* Config Editor tests -- MEDIUM tier.
*
* Tests Monaco editor loading, YAML content rendering,
* save button presence, and copy button interaction.
* Monaco load + value, Save (config/save?save_option=saveonly),
* Save error path, Save and Restart (WS frame via useRestart),
* Copy (clipboard), schema markers.
*/
import { test, expect } from "../fixtures/frigate-test";
import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames";
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
import {
getMonacoVisibleText,
replaceMonacoValue,
waitForErrorMarker,
} from "../helpers/monaco";
test.describe("Config Editor @medium", () => {
test("config editor loads Monaco editor with content", async ({
const SAMPLE_CONFIG =
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n";
async function installSaveRoute(
app: { page: import("@playwright/test").Page },
status: number,
body: Record<string, unknown>,
): Promise<{
capturedUrl: () => string | null;
capturedBody: () => string | null;
}> {
let lastUrl: string | null = null;
let lastBody: string | null = null;
await app.page.route("**/api/config/save**", async (route) => {
lastUrl = route.request().url();
lastBody = route.request().postData();
await route.fulfill({ status, json: body });
});
return {
capturedUrl: () => lastUrl,
capturedBody: () => lastBody,
};
}
test.describe("Config Editor — Monaco @medium", () => {
test("editor loads with mocked configRaw content", async ({ frigateApp }) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
// Assert via DOM-rendered visible text (Monaco virtualizes — works
// for short configs which covers our mocked content).
await expect
.poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 })
.toContain("front_door");
});
});
test.describe("Config Editor — Save @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Save button copy is desktop-visible (hidden md:block)",
);
test("clicking Save Only POSTs config/save?save_option=saveonly", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
const capture = await installSaveRoute(frigateApp, 200, {
message: "Config saved",
});
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
// Monaco editor should render with a specific class
const editor = frigateApp.page.locator(
".monaco-editor, [data-keybinding-context]",
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
await frigateApp.page.getByLabel("Save Only").click();
await expect
.poll(() => capture.capturedUrl(), { timeout: 5_000 })
.toMatch(/config\/save\?save_option=saveonly/);
// Body is the raw YAML as text/plain
await expect
.poll(() => capture.capturedBody(), { timeout: 5_000 })
.toContain("front_door");
});
test("config editor has action buttons", async ({ frigateApp }) => {
test("Save error shows the server message in the error area", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await installSaveRoute(frigateApp, 400, {
message: "Invalid field `cameras.front_door`",
});
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Save Only").click();
await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({
timeout: 5_000,
});
});
});
test("config editor button clicks do not crash", async ({ frigateApp }) => {
test.describe("Config Editor — Save and Restart @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Save and Restart button copy is desktop-visible",
);
test("Save and Restart opens dialog; confirm sends WS restart frame", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await installSaveRoute(frigateApp, 200, { message: "Saved" });
await installWsFrameCapture(frigateApp.page);
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
// Find buttons with SVG icons (copy, save, etc.)
const iconButtons = frigateApp.page.locator("button:has(svg)");
const count = await iconButtons.count();
if (count > 0) {
// Click the first icon button (likely copy)
await iconButtons.first().click();
await frigateApp.page.waitForTimeout(500);
}
await expect(frigateApp.page.locator("body")).toBeVisible();
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Save & Restart").click();
const dialog = frigateApp.page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole("button", { name: /restart/i }).click();
await waitForWsFrame(
frigateApp.page,
(frame) => frame.includes('"restart"') || frame.includes("restart"),
{ message: "useRestart should send a WS frame on the restart topic" },
);
});
test("cancelling the restart dialog leaves body interactive", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await installSaveRoute(frigateApp, 200, { message: "Saved" });
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Save & Restart").click();
const dialog = frigateApp.page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole("button", { name: /cancel/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
await expect(
frigateApp.page.locator(".monaco-editor").first(),
).toBeVisible();
});
});
test.describe("Config Editor — Copy @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Copy button copy is desktop-visible",
);
test("Copy places the editor value in the clipboard", async ({
frigateApp,
context,
}) => {
await grantClipboardPermissions(context);
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Copy Config").click();
await expect
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
.toContain("front_door");
});
});
test.describe("Config Editor — schema markers @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Schema validation assumes focused desktop editing",
);
test("invalid YAML renders at least one error marker in the DOM", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
// Replace editor contents with clearly invalid YAML via keyboard.
await replaceMonacoValue(
frigateApp.page,
"this is not: [yaml: and has {unbalanced",
);
// Monaco debounces marker evaluation; the .squiggly-error decoration
// appears asynchronously in the .view-overlays layer.
await waitForErrorMarker(frigateApp.page);
});
});
test.describe("Config Editor — Cmd+S keyboard shortcut @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Keyboard save shortcut is desktop-only",
);
test("Cmd/Ctrl+S fires the same config/save POST as the Save button", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
const capture = await installSaveRoute(frigateApp, 200, {
message: "Saved",
});
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
// Focus the editor so Monaco's keybinding receives the shortcut.
await frigateApp.page.locator(".monaco-editor").first().click();
await frigateApp.page.keyboard.press("ControlOrMeta+s");
await expect
.poll(() => capture.capturedUrl(), { timeout: 5_000 })
.toMatch(/config\/save\?save_option=saveonly/);
});
});
test.describe("Config Editor — Safe Mode auto-validation @medium", () => {
test("safe-mode config auto-posts on mount and shows the inline error", async ({
frigateApp,
}) => {
// Thread safe_mode: true through the config override, then stub
// config/save to return a validation error. The page's
// initialValidationRef effect runs on mount and POSTs
// config/save?save_option=saveonly with the raw config; the 400
// surfaces through setError.
// installDefaults must come first so our specific route wins (LIFO).
await frigateApp.installDefaults({
config: { safe_mode: true } as unknown as Record<string, unknown>,
configRaw: "cameras:\n front_door:\n ffmpeg: {}\n",
});
let autoSaveCalled = false;
await frigateApp.page.route("**/api/config/save**", async (route) => {
autoSaveCalled = true;
await route.fulfill({
status: 400,
json: { message: "safe-mode validation failure" },
});
});
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
await expect.poll(() => autoSaveCalled, { timeout: 10_000 }).toBe(true);
await expect(
frigateApp.page.getByText(/safe-mode validation failure/i),
).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Config Editor — mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("editor renders at narrow viewport", async ({ frigateApp }) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
});
});
+222 -54
View File
@@ -1,97 +1,265 @@
/**
* Explore page tests -- HIGH tier.
*
* Tests search input with text entry and clearing, camera filter popover
* opening with camera names, and content rendering with mock events.
* Search input, Enter submission, camera filter popover (desktop),
* event grid rendering with mocked events, mobile filter drawer.
*
* DEVIATION NOTES (from original plan):
*
* 1. Search input: InputWithTags is only rendered when
* config.semantic_search.enabled is true. Tests that exercise the search
* input override the config accordingly, using model:"genai" (not in the
* JINA_EMBEDDING_MODELS list) so the page skips local model-state checks
* and renders without waiting for model-download WS messages.
*
* 2. Filter buttons (Cameras, Labels, More Filters): SearchFilterGroup is
* only rendered when hasExistingSearch is true. Tests navigate with a URL
* param (?labels=person) to surface the filter bar.
*
* 3. Cameras button: accessible name is "Cameras Filter" (aria-label), not
* "All Cameras" (inner text). Use getByLabel("Cameras Filter").
*
* 4. Labels: button accessible name is "Labels" (aria-label). With
* ?labels=person, the text shows "Person" rather than "All Labels".
* Use getByLabel("Labels").
*
* 5. Sub-labels / Zones: These live inside the "More Filters" dialog
* (SearchFilterDialog), not as standalone top-level buttons. The Zones
* test opens "More Filters" and asserts zone content from config.
*
* 6. similarity_search_id URL param: This param does not exist in the app.
* The correct entrypoint for similarity search is
* ?search_type=similarity&event_id=<id>. The test uses this URL and
* polls for the resulting API request.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Explore Page - Search @high", () => {
test("explore page renders with filter buttons", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const buttons = frigateApp.page.locator("#pageRoot button");
await expect(buttons.first()).toBeVisible({ timeout: 10_000 });
});
// Semantic search config override used by multiple tests. Using model:
// "genai" (not in JINA_EMBEDDING_MODELS) sets isGenaiEmbeddings=true, which
// skips local model-state checks and lets the page render without waiting for
// individual model download WS messages. The WS mocker returns a completed
// reindexState so !reindexState is false and the loading gate clears.
const SEMANTIC_SEARCH_CONFIG = {
semantic_search: { enabled: true, model: "genai" },
} as const;
test("search input accepts text and can be cleared", async ({
frigateApp,
}) => {
// ---------------------------------------------------------------------------
// Search input (semantic_search must be enabled)
// ---------------------------------------------------------------------------
test.describe("Explore — search @high", () => {
test("search input accepts text and clears", async ({ frigateApp }) => {
// Enable semantic search so InputWithTags renders.
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill("person");
await expect(searchInput).toHaveValue("person");
await searchInput.fill("");
await expect(searchInput).toHaveValue("");
}
});
test("search input submits on Enter", async ({ frigateApp }) => {
test("Enter submission does not crash the page", async ({ frigateApp }) => {
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill("car in driveway");
await searchInput.press("Enter");
await frigateApp.page.waitForTimeout(1000);
// Page should not crash after search submit
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
}
});
});
test.describe("Explore Page - Filters @high", () => {
test("camera filter button opens popover with camera names (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
if (await camerasBtn.isVisible().catch(() => false)) {
// ---------------------------------------------------------------------------
// Filter bar — desktop only
// Filter buttons appear once hasExistingSearch is true (URL params present).
// ---------------------------------------------------------------------------
test.describe("Explore — filters (desktop) @high", () => {
test.skip(({ frigateApp }) => frigateApp.isMobile, "Desktop popovers");
test("Cameras popover lists configured cameras", async ({ frigateApp }) => {
// Navigate with a labels filter param so the filter bar renders.
await frigateApp.goto("/explore?labels=person");
// CamerasFilterButton has aria-label="Cameras Filter". Use getByLabel to
// match against the accessible name (not the inner "All Cameras" text).
const camerasBtn = frigateApp.page.getByLabel("Cameras Filter").first();
await expect(camerasBtn).toBeVisible({ timeout: 10_000 });
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
// DropdownMenu on desktop wraps content in data-radix-popper-content-wrapper.
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Camera names from config should be in the popover
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
}
});
test("filter button opens and closes overlay cleanly", async ({
test("Labels filter lists labels from config", async ({ frigateApp }) => {
// Navigate with an existing search so the filter bar renders.
await frigateApp.goto("/explore?labels=person");
// GeneralFilterButton has aria-label="Labels". With ?labels=person the
// button text shows "Person" (the selected label), but the aria-label
// remains "Labels".
const labelsBtn = frigateApp.page.getByLabel("Labels").first();
await expect(labelsBtn).toBeVisible({ timeout: 10_000 });
await labelsBtn.click();
// PlatformAwareDialog renders on desktop as a dropdown/popover overlay.
const overlay = frigateApp.page.locator(
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
);
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
// "person" is already selected (it's in the URL); assert it appears in
// the overlay content.
await expect(overlay.first().getByText(/person/i)).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
});
test("Sub-labels filter renders inside More Filters dialog", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const firstButton = frigateApp.page.locator("#pageRoot button").first();
await expect(firstButton).toBeVisible({ timeout: 5_000 });
await firstButton.click();
await frigateApp.page.waitForTimeout(500);
// Sub-labels live inside SearchFilterDialog ("More Filters" button).
// With sub_labels mocked as [], the section still renders its heading.
await frigateApp.page.route("**/api/sub_labels**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.goto("/explore?labels=person");
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
await moreBtn.click();
const overlay = frigateApp.page.locator(
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
);
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
// "Sub Labels" section heading always renders inside the dialog.
await expect(
frigateApp.page.getByText(/sub.?label/i).first(),
).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
});
test("Zones filter lists configured zones inside More Filters dialog", async ({
frigateApp,
}) => {
// Override config to guarantee a known zone on front_door.
await frigateApp.installDefaults({
config: {
cameras: {
front_door: {
zones: {
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
},
},
},
},
});
await frigateApp.goto("/explore?labels=person");
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
await moreBtn.click();
const overlay = frigateApp.page.locator(
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
);
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
await expect(frigateApp.page.getByText(/front.?yard/i)).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
// Page is still functional after open/close cycle
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Explore Page - Content @high", () => {
test("explore page shows content with mock events", async ({
// ---------------------------------------------------------------------------
// Content
// ---------------------------------------------------------------------------
test.describe("Explore — content @high", () => {
test("page renders with mock events", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
await expect(
frigateApp.page.locator("#pageRoot button").first(),
).toBeVisible({ timeout: 10_000 });
});
test("empty events renders without crash", async ({ frigateApp }) => {
await frigateApp.installDefaults({ events: [] });
await frigateApp.goto("/explore");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
});
test("search fires a /api/events request with the query", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
const eventsRequests: string[] = [];
frigateApp.page.on("request", (req) => {
const url = req.url();
if (/\/api\/events/.test(url)) eventsRequests.push(url);
});
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(3000);
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
const searchInput = frigateApp.page.locator("input").first();
await expect(searchInput).toBeVisible({ timeout: 10_000 });
const before = eventsRequests.length;
await searchInput.fill("person in driveway");
await searchInput.press("Enter");
await expect
.poll(() => eventsRequests.length > before, { timeout: 5_000 })
.toBe(true);
});
});
// ---------------------------------------------------------------------------
// Similarity search URL param
// ---------------------------------------------------------------------------
test.describe("Explore — similarity search (desktop) @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Similarity trigger is hover-based; desktop-focused",
);
test("URL similarity search params fetch events", async ({ frigateApp }) => {
const eventsRequests: string[] = [];
frigateApp.page.on("request", (req) => {
const url = req.url();
if (/\/api\/events/.test(url)) eventsRequests.push(url);
});
// The app uses search_type=similarity&event_id=<id> (not
// similarity_search_id). This exercises the same similarity search code
// path as clicking "Find Similar" on a thumbnail.
// Use a valid event-id format (timestamp.fractional-alphanumeric).
await frigateApp.goto(
"/explore?search_type=similarity&event_id=1712412000.000000-abc123",
);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
// Poll to allow any pending SWR fetch to complete and be captured.
await expect
.poll(() => eventsRequests.length, { timeout: 5_000 })
.toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Mobile
// ---------------------------------------------------------------------------
test.describe("Explore — mobile @high @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("search input is focusable at mobile viewport", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
await frigateApp.goto("/explore");
const searchInput = frigateApp.page.locator("input").first();
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.focus();
await expect(searchInput).toBeFocused();
});
});
+373 -15
View File
@@ -1,32 +1,390 @@
/**
* Face Library page tests -- MEDIUM tier.
* Face Library page tests -- HIGH tier.
*
* Tests face grid rendering, empty state, and interactive controls.
* Collection selector, face tiles, grouped recent-recognition dialog
* (migrated from radix-overlay-regressions.spec.ts), and mobile
* library selector.
*/
import { test, expect } from "../fixtures/frigate-test";
import { type Locator } from "@playwright/test";
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import {
basicFacesMock,
emptyFacesMock,
withGroupedTrainingAttempt,
} from "../fixtures/mock-data/faces";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
test.describe("Face Library @medium", () => {
test("face library page renders without crash", async ({ frigateApp }) => {
const GROUPED_EVENT_ID = "1775487131.3863528-abc123";
function groupedFacesMock() {
return withGroupedTrainingAttempt(basicFacesMock(), {
eventId: GROUPED_EVENT_ID,
attempts: [
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
{ timestamp: 1775487132.3863528, label: "unknown", score: 0.91 },
],
});
}
async function installGroupedFaces(app: FrigateApp) {
await app.api.install({
events: [
{
id: GROUPED_EVENT_ID,
label: "person",
sub_label: null,
camera: "front_door",
start_time: 1775487131.3863528,
end_time: 1775487161.3863528,
false_positive: false,
zones: ["front_yard"],
thumbnail: null,
has_clip: true,
has_snapshot: true,
retain_indefinitely: false,
plus_id: null,
model_hash: "abc123",
detector_type: "cpu",
model_type: "ssd",
data: {
top_score: 0.92,
score: 0.92,
region: [0.1, 0.1, 0.5, 0.8],
box: [0.2, 0.15, 0.45, 0.75],
area: 0.18,
ratio: 0.6,
type: "object",
path_data: [],
},
},
],
faces: groupedFacesMock(),
});
}
async function openGroupedFaceDialog(app: FrigateApp): Promise<Locator> {
await installGroupedFaces(app);
await app.goto("/faces");
const groupedImage = app.page
.locator('img[src*="clips/faces/train/"]')
.first();
const groupedCard = groupedImage.locator("xpath=..");
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
await groupedCard.click();
const dialog = app.page
.getByRole("dialog")
.filter({ has: app.page.locator('img[src*="clips/faces/train/"]') })
.first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.locator('img[src*="clips/faces/train/"]')).toHaveCount(2);
return dialog;
}
/**
* Opens the LibrarySelector dropdown (the single button at the top-left of
* the Face Library page) and returns the dropdown menu locator.
*
* The LibrarySelector is a single DropdownMenu whose trigger shows the
* current tab name + count (e.g. "Recent Recognitions (0)"). Named face
* collections (alice, bob, charlie) are items inside this dropdown.
*/
async function openLibraryDropdown(app: FrigateApp): Promise<Locator> {
// The trigger is the first button on the page with a parenthesised count.
const trigger = app.page
.getByRole("button")
.filter({ hasText: /\(\d+\)/ })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const menu = app.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
return menu;
}
test.describe("Face Library — collection selector @high", () => {
test("selector shows named face collections", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.goto("/faces");
// Named collections appear in the LibrarySelector dropdown.
const menu = await openLibraryDropdown(frigateApp);
await expect(menu.getByText(/alice/i).first()).toBeVisible({
timeout: 5_000,
});
});
test("empty state renders when no faces exist", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: emptyFacesMock() });
await frigateApp.goto("/faces");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await expect(
frigateApp.page.locator('img[src*="/clips/faces/"]'),
).toHaveCount(0);
});
test("face library shows empty state with no faces", async ({
test("tiles render for each named collection", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.goto("/faces");
// Open the dropdown — collections list shows "alice (2)" and "bob (1)".
const menu = await openLibraryDropdown(frigateApp);
await expect(
menu.locator('[role="menuitem"]').filter({ hasText: /alice/i }).first(),
).toBeVisible({ timeout: 5_000 });
await expect(
menu.locator('[role="menuitem"]').filter({ hasText: /bob/i }).first(),
).toBeVisible();
});
});
test.describe("Face Library — delete flow (desktop) @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Delete action menu is desktop-focused",
);
test("deleting a collection fires POST /faces/<name>/delete", async ({
frigateApp,
}) => {
let deleteUrl: string | null = null;
let deleteBody: unknown = null;
// Install base mocks first, then register our more-specific route AFTER
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.page.route(
/\/api\/faces\/[^/]+\/delete/,
async (route) => {
deleteUrl = route.request().url();
deleteBody = route.request().postDataJSON();
await route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/faces");
await frigateApp.page.waitForTimeout(2000);
// With empty faces mock, should show empty state or content
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
// Open the LibrarySelector dropdown and click the trash icon next
// to the alice row. The trash icon is a ghost-variant Button inside
// the DropdownMenuItem — it becomes visible on hover/focus.
const menu = await openLibraryDropdown(frigateApp);
const aliceRow = menu
.locator('[role="menuitem"]')
.filter({ hasText: /alice/i })
.first();
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
// Hover first to make hover-only opacity-0 buttons visible.
await aliceRow.hover();
// The icon buttons have no aria-label or title. The row renders exactly
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
// (delete). This order is determined by FaceLibrary.tsx and is stable.
const trashBtn = aliceRow.locator("button").nth(1);
await trashBtn.click();
// The delete confirmation is a Dialog (not AlertDialog) in this flow.
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog
.getByRole("button", { name: /delete/i })
.first()
.click();
await expect
.poll(() => deleteUrl, { timeout: 5_000 })
.toMatch(/\/faces\/alice\/delete/);
expect(deleteBody).toMatchObject({ ids: expect.any(Array) });
});
});
test("face library has interactive buttons", async ({ frigateApp }) => {
test.describe("Face Library — rename flow (desktop) @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Rename action menu is desktop-focused",
);
test("renaming a collection fires PUT /faces/<name>/rename", async ({
frigateApp,
}) => {
let renameUrl: string | null = null;
let renameBody: unknown = null;
// Install base mocks first, then register our more-specific route AFTER
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.page.route(
/\/api\/faces\/[^/]+\/rename/,
async (route) => {
renameUrl = route.request().url();
renameBody = route.request().postDataJSON();
await route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/faces");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
// Open the LibrarySelector dropdown and click the pencil (rename) icon
// next to alice. The icon is a ghost Button inside the DropdownMenuItem.
const menu = await openLibraryDropdown(frigateApp);
const aliceRow = menu
.locator('[role="menuitem"]')
.filter({ hasText: /alice/i })
.first();
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
await aliceRow.hover();
// The icon buttons have no aria-label or title. The row renders exactly
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
// (delete). This order is determined by FaceLibrary.tsx and is stable.
const pencilBtn = aliceRow.locator("button").nth(0);
await pencilBtn.click();
// TextEntryDialog — fill the input and confirm.
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.locator("input").first().fill("alice_renamed");
await dialog
.getByRole("button", { name: /save|rename|confirm/i })
.first()
.click();
await expect
.poll(() => renameUrl, { timeout: 5_000 })
.toMatch(/\/faces\/alice\/rename/);
expect(renameBody).toEqual({ new_name: "alice_renamed" });
});
});
test.describe("Face Library — upload flow @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Upload button has no accessible text on mobile — icon-only on narrow viewports",
);
test("Upload button opens the upload dialog", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.goto("/faces");
// Navigate to the alice tab by opening the dropdown and clicking alice.
const menu = await openLibraryDropdown(frigateApp);
await menu
.locator('[role="menuitem"]')
.filter({ hasText: /alice/i })
.first()
.click();
// After switching to alice, the Upload Image button appears in the toolbar.
const uploadBtn = frigateApp.page
.getByRole("button")
.filter({ hasText: /upload/i })
.first();
await expect(uploadBtn).toBeVisible({ timeout: 5_000 });
await uploadBtn.click();
// UploadImageDialog renders a file input + confirm button.
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.locator('input[type="file"]')).toHaveCount(1);
});
});
test.describe("FaceSelectionDialog @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Grouped dropdown flow is desktop-only",
);
test("reclassify dropdown selects a name and closes cleanly", async ({
frigateApp,
}) => {
// Migrated from radix-overlay-regressions.spec.ts.
const dialog = await openGroupedFaceDialog(frigateApp);
const triggers = dialog.locator('[aria-haspopup="menu"]');
await expect(triggers).toHaveCount(2);
await triggers.first().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await expect(dialog).toBeVisible();
const tooltipVisible = await frigateApp.page
.locator('[role="tooltip"]')
.filter({ hasText: /train face/i })
.isVisible()
.catch(() => false);
expect(
tooltipVisible,
"Train Face tooltip popped after dropdown closed — focus-restore regression",
).toBe(false);
});
test("second dropdown open accepts typeahead keyboard input", async ({
frigateApp,
}) => {
// Migrated from radix-overlay-regressions.spec.ts.
const dialog = await openGroupedFaceDialog(frigateApp);
const triggers = dialog.locator('[aria-haspopup="menu"]');
await expect(triggers).toHaveCount(2);
await triggers.first().click();
let menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await triggers.nth(1).click();
menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await frigateApp.page.keyboard.press("c");
await expect
.poll(
async () =>
frigateApp.page.evaluate(
() =>
document.activeElement?.textContent?.trim().toLowerCase() ?? "",
),
{ timeout: 2_000 },
)
.toMatch(/^charlie/);
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible({ timeout: 3_000 });
});
});
test.describe("Face Library — mobile @high @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("mobile library selector dropdown closes cleanly on Escape", async ({
frigateApp,
}) => {
// Migrated from radix-overlay-regressions.spec.ts.
await installGroupedFaces(frigateApp);
await frigateApp.goto("/faces");
const selector = frigateApp.page
.getByRole("button")
.filter({ hasText: /\(\d+\)/ })
.first();
await expect(selector).toBeVisible({ timeout: 5_000 });
await selector.click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
});
});
+237 -201
View File
@@ -1,59 +1,47 @@
/**
* Live page tests -- CRITICAL tier.
*
* Tests camera dashboard rendering, camera card clicks, single camera view
* with named controls, feature toggle behavior, context menu, and mobile layout.
* Dashboard grid, single-camera controls, feature toggles (with WS
* frame assertions), context menu, birdseye, and mobile layout.
* Also absorbs the PTZ preset-dropdown regression tests from the
* now-deleted ptz-overlay.spec.ts.
*/
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
const PTZ_CAMERA = "front_door";
const PRESET_NAMES = ["home", "driveway", "front_porch"];
test.describe("Live Dashboard @critical", () => {
test("dashboard renders all configured cameras by name", async ({
test("every configured camera renders on the dashboard", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
for (const cam of ["front_door", "backyard", "garage"]) {
await expect(
frigateApp.page.locator(`[data-camera='${cam}']`),
).toBeVisible({ timeout: 10_000 });
await expect(live.cameraCard(cam)).toBeVisible({ timeout: 10_000 });
}
});
test("clicking camera card opens single camera view via hash", async ({
test("clicking a camera card opens the single-camera view via hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
await live.cameraCard("front_door").first().click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
test("back button returns from single camera to dashboard", async ({
frigateApp,
}) => {
// First navigate to dashboard so there's history to go back to
await frigateApp.goto("/");
await frigateApp.page.waitForTimeout(1000);
// Click a camera to enter single view
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await frigateApp.page.waitForTimeout(2000);
// Now click Back to return to dashboard
const backBtn = frigateApp.page.getByText("Back", { exact: true });
if (await backBtn.isVisible().catch(() => false)) {
await backBtn.click();
await frigateApp.page.waitForTimeout(1000);
}
// Should be back on the dashboard with cameras visible
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("birdseye view loads without crash", async ({ frigateApp }) => {
test("birdseye route renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/#birdseye");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("empty group shows fallback content", async ({ frigateApp }) => {
@@ -63,191 +51,239 @@ test.describe("Live Dashboard @critical", () => {
});
});
test.describe("Live Single Camera - Controls @critical", () => {
test("single camera view shows Back and History buttons (desktop)", async ({
test.describe("Live Single Camera — desktop controls @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Desktop-only header controls",
);
test("single-camera view shows Back and History buttons", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip(); // On mobile, buttons may show icons only
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Back and History are visible text buttons in the header
await expect(
frigateApp.page.getByText("Back", { exact: true }),
).toBeVisible({ timeout: 5_000 });
await expect(
frigateApp.page.getByText("History", { exact: true }),
).toBeVisible();
const live = new LivePage(frigateApp.page, true);
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
await expect(live.historyButton).toBeVisible();
});
test("single camera view shows feature toggle icons (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
test("feature toggles render (at least 3)", async ({ frigateApp }) => {
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Feature toggles are CameraFeatureToggle components rendered as divs
// with bg-selected (active) or bg-secondary (inactive) classes
// Count the toggles - should have at least detect, recording, snapshots
const toggles = frigateApp.page.locator(
".flex.flex-col.items-center.justify-center.bg-selected, .flex.flex-col.items-center.justify-center.bg-secondary",
);
const count = await toggles.count();
const live = new LivePage(frigateApp.page, true);
// Wait for the single-camera header to render before counting toggles.
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
await expect(live.featureToggles.first()).toBeVisible({ timeout: 5_000 });
const count = await live.featureToggles.count();
expect(count).toBeGreaterThanOrEqual(3);
});
test("clicking a feature toggle changes its visual state (desktop)", async ({
test("clicking a feature toggle sends the matching WS frame", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await installWsFrameCapture(frigateApp.page);
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Find active toggles (bg-selected class = feature is ON)
const activeToggles = frigateApp.page.locator(
".flex.flex-col.items-center.justify-center.bg-selected",
const live = new LivePage(frigateApp.page, true);
// Wait for feature toggles to render (WS camera_activity must arrive first).
await expect(live.activeFeatureToggles.first()).toBeVisible({
timeout: 5_000,
});
const activeBefore = await live.activeFeatureToggles.count();
expect(activeBefore).toBeGreaterThan(0);
await live.activeFeatureToggles.first().click();
// The toggle dispatches a frame on <camera>/<feature>/set — match on
// front_door/ prefix + /set suffix (any feature).
await waitForWsFrame(
frigateApp.page,
(frame) => frame.includes("front_door/") && frame.includes("/set"),
{
message:
"feature toggle should dispatch a <camera>/<feature>/set frame",
},
);
const initialCount = await activeToggles.count();
if (initialCount > 0) {
// Click the first active toggle to disable it
await activeToggles.first().click();
await frigateApp.page.waitForTimeout(1000);
// After WS mock echoes back new state, count should decrease
const newCount = await activeToggles.count();
expect(newCount).toBeLessThan(initialCount);
}
});
test("settings gear button opens dropdown (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Find the gear icon button (last button-like element in header)
// The settings gear opens a dropdown with Stream, Play in background, etc.
const gearButtons = frigateApp.page.locator("button:has(svg)");
const count = await gearButtons.count();
// Click the last one (gear icon is typically last in the header)
if (count > 0) {
await gearButtons.last().click();
await frigateApp.page.waitForTimeout(500);
// A dropdown or drawer should appear
const overlay = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content], [role="dialog"]',
);
const visible = await overlay
.first()
.isVisible()
.catch(() => false);
if (visible) {
await frigateApp.page.keyboard.press("Escape");
}
}
});
test("keyboard shortcut f does not crash on desktop", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
test("keyboard shortcut f does not crash", async ({ frigateApp }) => {
await frigateApp.goto("/");
await frigateApp.page.keyboard.press("f");
await frigateApp.page.waitForTimeout(500);
await expect(frigateApp.page.locator("body")).toBeVisible();
// Note: headless Chromium rejects fullscreen requests without a user
// gesture, so document.fullscreenElement cannot be asserted reliably
// in e2e. We assert the keypress doesn't crash the app; real
// fullscreen behavior is covered by manual testing.
});
test("settings gear opens a dropdown with Stream/Play menu items", async ({
frigateApp,
}) => {
await frigateApp.goto("/#front_door");
// Wait for the single-camera view to render — use the Back button
// as a deterministic marker.
const live = new LivePage(frigateApp.page, true);
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
// The gear icon button is the last button-like element in the
// single-camera header. Clicking it opens a Radix dropdown.
const gearButtons = frigateApp.page.locator("button:has(svg)");
const count = await gearButtons.count();
expect(count).toBeGreaterThan(0);
await gearButtons.last().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible({ timeout: 3_000 });
});
});
test.describe("Live Context Menu (desktop) @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Right-click is desktop-only",
);
test("right-click opens the context menu", async ({ frigateApp }) => {
await frigateApp.goto("/");
const live = new LivePage(frigateApp.page, true);
const menu = await live.openContextMenuOn("front_door");
await expect(menu).toBeVisible({ timeout: 5_000 });
});
test("context menu closes on Escape and leaves body interactive", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const live = new LivePage(frigateApp.page, true);
const menu = await live.openContextMenuOn("front_door");
await expect(menu).toBeVisible({ timeout: 5_000 });
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible();
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
});
});
test.describe("Live PTZ preset dropdown @critical", () => {
// Migrated from ptz-overlay.spec.ts. Guards:
// 1. After selecting a preset, the "Presets" tooltip must not re-pop.
// 2. Keyboard shortcuts after close should not re-open the dropdown.
test("selecting a preset closes menu cleanly and does not re-open on keyboard", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "PTZ preset dropdown is desktop-only");
await frigateApp.api.install({
config: {
cameras: {
[PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } },
},
},
});
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
route.fulfill({
json: {
name: PTZ_CAMERA,
features: ["pt", "zoom"],
presets: PRESET_NAMES,
profiles: [],
},
}),
);
await installWsFrameCapture(frigateApp.page);
await frigateApp.goto(`/#${PTZ_CAMERA}`);
const presetTrigger = frigateApp.page.getByRole("button", {
name: /presets/i,
});
await expect(presetTrigger.first()).toBeVisible({ timeout: 5_000 });
await presetTrigger.first().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
await menu.getByRole("menuitem", { name: PRESET_NAMES[0] }).first().click();
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await waitForWsFrame(
frigateApp.page,
(frame) =>
frame.includes(`"${PTZ_CAMERA}/ptz"`) &&
frame.includes(`preset_${PRESET_NAMES[0]}`),
);
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
await expect
.poll(
async () =>
frigateApp.page
.locator('[role="tooltip"]')
.filter({ hasText: /presets/i })
.isVisible()
.catch(() => false),
{ timeout: 1_000 },
)
.toBe(false);
await frigateApp.page.keyboard.press("ArrowUp");
await frigateApp.page.keyboard.press("Space");
await frigateApp.page.keyboard.press("Enter");
await expect
.poll(() => menu.isVisible().catch(() => false), { timeout: 1_000 })
.toBe(false);
});
});
test.describe("Live mobile layout @critical @mobile", () => {
test("mobile dashboard has no sidebar and renders cameras", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.goto("/");
await expect(frigateApp.page.locator("aside")).toHaveCount(0);
const live = new LivePage(frigateApp.page, false);
await expect(live.cameraCard("front_door")).toBeVisible({
timeout: 10_000,
});
});
test("mobile camera tap opens single view", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.goto("/");
const live = new LivePage(frigateApp.page, false);
await live.cameraCard("front_door").first().click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
test("mobile onvif single-camera view loads without freezing body", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
// Migrated from ptz-overlay.spec.ts — dismissable-layer dedupe smoke test.
await frigateApp.api.install({
config: {
cameras: { [PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } } },
},
});
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
route.fulfill({
json: {
name: PTZ_CAMERA,
features: ["pt", "zoom"],
presets: PRESET_NAMES,
profiles: [],
},
}),
);
await frigateApp.goto(`/#${PTZ_CAMERA}`);
await expectBodyInteractive(frigateApp.page);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});
test.describe("Live Single Camera - Mobile Controls @critical", () => {
test("mobile camera view has settings drawer trigger", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// On mobile, settings gear opens a drawer
// The button has aria-label with the camera name like "front_door Settings"
const buttons = frigateApp.page.locator("button:has(svg)");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe("Live Context Menu @critical", () => {
test("right-click on camera opens context menu on desktop", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.waitFor({ state: "visible", timeout: 10_000 });
await card.click({ button: "right" });
const contextMenu = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content]',
);
await expect(contextMenu.first()).toBeVisible({ timeout: 5_000 });
});
test("context menu closes on escape", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.waitFor({ state: "visible", timeout: 10_000 });
await card.click({ button: "right" });
await frigateApp.page.waitForTimeout(500);
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
const contextMenu = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content]',
);
await expect(contextMenu).not.toBeVisible();
});
});
test.describe("Live Mobile Layout @critical", () => {
test("mobile renders cameras without sidebar", async ({ frigateApp }) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(frigateApp.page.locator("aside")).not.toBeVisible();
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("mobile camera click opens single camera view", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
});
+209 -62
View File
@@ -1,75 +1,222 @@
/**
* Logs page tests -- MEDIUM tier.
*
* Tests service tab switching by name, copy/download buttons,
* and websocket message feed tab.
* Service tabs (with real /logs/<service> JSON contract),
* log content render, Copy (clipboard), Download (assert
* ?download=true request fired), mobile tab selector.
*/
import { test, expect } from "../fixtures/frigate-test";
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
test.describe("Logs Page - Service Tabs @medium", () => {
test("logs page renders with named service tabs", async ({ frigateApp }) => {
function logsJsonBody(lines: string[]) {
return { lines, totalLines: lines.length };
}
test.describe("Logs — service tabs @medium", () => {
test("frigate tab renders by default with mocked log lines", async ({
frigateApp,
}) => {
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
route.fulfill({
json: logsJsonBody([
"[2026-04-06 10:00:00] INFO: Frigate started",
"[2026-04-06 10:00:01] INFO: Cameras loaded",
]),
}),
);
// Silence the streaming fetch so it doesn't hang the test.
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
route.fulfill({ status: 200, body: "" }),
);
await frigateApp.goto("/logs");
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({
timeout: 10_000,
});
});
test("switching to go2rtc fires a GET to /logs/go2rtc", async ({
frigateApp,
}) => {
let go2rtcCalled = false;
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
);
await frigateApp.page.route(/\/api\/logs\/go2rtc(\?|$)/, (route) => {
if (!route.request().url().includes("stream=true")) {
go2rtcCalled = true;
}
return route.fulfill({ json: logsJsonBody(["go2rtc line"]) });
});
await frigateApp.page.route(/\/api\/logs\/.*\?stream=true/, (route) =>
route.fulfill({ status: 200, body: "" }),
);
await frigateApp.goto("/logs");
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
await expect(go2rtcTab).toBeVisible();
await go2rtcTab.click();
await expect.poll(() => go2rtcCalled, { timeout: 5_000 }).toBe(true);
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
});
});
test.describe("Logs — actions @medium", () => {
test("Copy button writes current logs to clipboard", async ({
frigateApp,
context,
}) => {
await grantClipboardPermissions(context);
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
route.fulfill({
json: logsJsonBody([
"[2026-04-06 10:00:00] INFO: Frigate started",
"[2026-04-06 10:00:01] INFO: Cameras loaded",
]),
}),
);
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
route.fulfill({ status: 200, body: "" }),
);
await frigateApp.goto("/logs");
await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({
timeout: 10_000,
});
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
await expect(copyBtn).toBeVisible({ timeout: 5_000 });
await copyBtn.click();
await expect
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
.toContain("Frigate started");
});
test("Download button fires GET /logs/<service>?download=true", async ({
frigateApp,
}) => {
let downloadCalled = false;
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => {
if (route.request().url().includes("download=true")) {
downloadCalled = true;
}
return route.fulfill({ json: logsJsonBody(["frigate line"]) });
});
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
route.fulfill({ status: 200, body: "" }),
);
await frigateApp.goto("/logs");
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
await expect(downloadBtn).toBeVisible({ timeout: 5_000 });
await downloadBtn.click();
await expect.poll(() => downloadCalled, { timeout: 5_000 }).toBe(true);
});
});
test.describe("Logs — websocket tab @medium", () => {
test("switching to websocket tab renders WsMessageFeed container", async ({
frigateApp,
}) => {
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
);
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
route.fulfill({ status: 200, body: "" }),
);
await frigateApp.goto("/logs");
const wsTab = frigateApp.page.getByLabel("Select websocket");
await expect(wsTab).toBeVisible({ timeout: 5_000 });
await wsTab.click();
await expect(wsTab).toHaveAttribute("data-state", "on", { timeout: 5_000 });
});
});
test.describe("Logs — streaming @medium", () => {
test("streamed log lines appear in the viewport", async ({ frigateApp }) => {
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => {
if (route.request().url().includes("stream=true")) {
// Intercepted below via addInitScript fetch override.
return route.fallback();
}
return route.fulfill({
json: logsJsonBody(["[2026-04-06 10:00:00] INFO: initial batch line"]),
});
});
// Override window.fetch so the /api/logs/frigate?stream=true request
// resolves with a real ReadableStream that emits chunks over time.
// This is the only way to validate streaming-append behavior through
// Playwright — route.fulfill() cannot return a stream.
// NOTE: The app calls fetch('api/logs/...') with a relative URL (no
// leading slash), so we match both relative and absolute forms.
await frigateApp.page.addInitScript(() => {
const origFetch = window.fetch;
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
if (url.includes("api/logs/frigate") && url.includes("stream=true")) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
await new Promise((r) => setTimeout(r, 30));
controller.enqueue(
encoder.encode(
"[2026-04-06 10:00:02] INFO: streamed line one\n",
),
);
await new Promise((r) => setTimeout(r, 30));
controller.enqueue(
encoder.encode(
"[2026-04-06 10:00:03] INFO: streamed line two\n",
),
);
controller.close();
},
});
return new Response(stream, { status: 200 });
}
return origFetch.call(window, input as RequestInfo, init);
};
});
await frigateApp.goto("/logs");
// The initial batch line is parsed by LogLineData and its content is
// rendered in a .log-content cell — assert against that element.
await expect(frigateApp.page.getByText("initial batch line")).toBeVisible({
timeout: 10_000,
});
await expect(frigateApp.page.getByText(/streamed line one/)).toBeVisible({
timeout: 10_000,
});
await expect(frigateApp.page.getByText(/streamed line two/)).toBeVisible({
timeout: 10_000,
});
});
});
test.describe("Logs — mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("service tabs render at mobile viewport", async ({ frigateApp }) => {
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
);
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
route.fulfill({ status: 200, body: "" }),
);
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// Service tabs have aria-label="Select {service}"
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
test("switching to go2rtc tab changes active tab", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
if (await go2rtcTab.isVisible().catch(() => false)) {
await go2rtcTab.click();
await frigateApp.page.waitForTimeout(1000);
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
}
});
test("switching to websocket tab shows message feed", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const wsTab = frigateApp.page.getByLabel("Select websocket");
if (await wsTab.isVisible().catch(() => false)) {
await wsTab.click();
await frigateApp.page.waitForTimeout(1000);
await expect(wsTab).toHaveAttribute("data-state", "on");
}
});
});
test.describe("Logs Page - Actions @medium", () => {
test("copy to clipboard button is present and clickable", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
if (await copyBtn.isVisible().catch(() => false)) {
await copyBtn.click();
await frigateApp.page.waitForTimeout(500);
// Should trigger clipboard copy (toast may appear)
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("download logs button is present", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
if (await downloadBtn.isVisible().catch(() => false)) {
await expect(downloadBtn).toBeVisible();
}
});
test("logs page displays log content text", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
});
+91 -152
View File
@@ -1,103 +1,78 @@
/**
* Navigation tests -- CRITICAL tier.
*
* Tests sidebar (desktop) and bottombar (mobile) navigation,
* conditional nav items, settings menus, and their actual behaviors.
* Covers sidebar (desktop) / bottombar (mobile) link set, conditional
* nav items (faces, chat, classification), settings menu navigation,
* unknown-route redirect to /, and mobile-specific nav behaviors.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Navigation @critical", () => {
test("app loads and renders page root", async ({ frigateApp }) => {
await frigateApp.goto("/");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
const PRIMARY_ROUTES = ["/review", "/explore", "/export"] as const;
test("logo is visible and links to home", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, true);
const logo = base.sidebar.locator('a[href="/"]').first();
await expect(logo).toBeVisible();
});
test("all primary nav links are present and navigate", async ({
test.describe("Navigation — primary links @critical", () => {
test("every primary link is visible and navigates", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const routes = ["/review", "/explore", "/export"];
for (const route of routes) {
for (const route of PRIMARY_ROUTES) {
await expect(
frigateApp.page.locator(`a[href="${route}"]`).first(),
).toBeVisible();
}
// Verify clicking each one actually navigates
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
for (const route of routes) {
for (const route of PRIMARY_ROUTES) {
await base.navigateTo(route);
await expect(frigateApp.page).toHaveURL(new RegExp(route));
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
}
});
test("desktop sidebar is visible, mobile bottombar is visible", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
if (!frigateApp.isMobile) {
await expect(base.sidebar).toBeVisible();
} else {
await expect(base.sidebar).not.toBeVisible();
}
test("logo links home on desktop", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Sidebar logo is desktop-only");
await frigateApp.goto("/review");
await frigateApp.page.locator("aside a[href='/']").first().click();
await expect(frigateApp.page).toHaveURL(/\/$/);
});
test("navigate between all main pages without crash", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
const pageRoot = frigateApp.page.locator("#pageRoot");
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/explore");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/export");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
});
test("unknown route redirects to home", async ({ frigateApp }) => {
test("unknown route redirects to /", async ({ frigateApp }) => {
await frigateApp.page.goto("/nonexistent-route");
await frigateApp.page.waitForTimeout(2000);
const url = frigateApp.page.url();
const hasPageRoot = await frigateApp.page
.locator("#pageRoot")
.isVisible()
.catch(() => false);
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/\/$/);
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Navigation - Conditional Items @critical", () => {
test("Faces nav hidden when face_recognition disabled", async ({
test.describe("Navigation — conditional items @critical", () => {
test("/faces is hidden when face_recognition.enabled is false", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
await expect(frigateApp.page.locator('a[href="/faces"]')).not.toBeVisible();
await expect(
frigateApp.page.locator('a[href="/faces"]').first(),
).toHaveCount(0);
});
test("Chat nav hidden when genai model is none", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
test("/faces is visible when face_recognition.enabled is true (desktop)", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
});
await frigateApp.goto("/");
await expect(
frigateApp.page.locator('a[href="/faces"]').first(),
).toBeVisible();
});
test("/chat is hidden when genai.model is none (desktop)", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.installDefaults({
config: {
genai: {
@@ -109,119 +84,83 @@ test.describe("Navigation - Conditional Items @critical", () => {
},
});
await frigateApp.goto("/");
await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible();
await expect(
frigateApp.page.locator('a[href="/chat"]').first(),
).toHaveCount(0);
});
test("Faces nav visible when face_recognition enabled on desktop", async ({
test("/chat is visible when genai.model is set (desktop)", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/faces"]')).toBeVisible();
});
test("Chat nav visible when genai model set on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.installDefaults({
config: { genai: { enabled: true, model: "llava" } },
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/chat"]')).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/chat"]').first(),
).toBeVisible();
});
test("Classification nav visible for admin on desktop", async ({
test("/classification is visible for admin on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.goto("/");
await expect(page.locator('a[href="/classification"]')).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/classification"]').first(),
).toBeVisible();
});
});
test.describe("Navigation - Settings Menu @critical", () => {
test("settings gear opens menu with navigation items (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// Settings gear is in the sidebar bottom section, a div with cursor-pointer
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const gearIcon = sidebarBottom
.locator("div[class*='cursor-pointer']")
.first();
await expect(gearIcon).toBeVisible({ timeout: 5_000 });
await gearIcon.click();
// Menu should open - look for the "Settings" menu item by aria-label
await expect(frigateApp.page.getByLabel("Settings")).toBeVisible({
timeout: 3_000,
});
});
test.describe("Navigation — settings menu (desktop) @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Sidebar settings menu is desktop-only",
);
test("settings menu items navigate to correct routes (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const targets = [
{ label: "Settings", url: "/settings" },
{ label: "System metrics", url: "/system" },
{ label: "System logs", url: "/logs" },
{ label: "Configuration Editor", url: "/config" },
const TARGETS = [
{ label: "Settings", url: /\/settings/ },
{ label: "System metrics", url: /\/system/ },
{ label: "System logs", url: /\/logs/ },
{ label: "Configuration Editor", url: /\/config/ },
];
for (const target of targets) {
for (const target of TARGETS) {
test(`menu → ${target.label} navigates`, async ({ frigateApp }) => {
await frigateApp.goto("/");
const gearIcon = frigateApp.page
const gear = frigateApp.page
.locator("aside .mb-8 div[class*='cursor-pointer']")
.first();
await gearIcon.click();
await frigateApp.page.waitForTimeout(300);
const menuItem = frigateApp.page.getByLabel(target.label);
if (await menuItem.isVisible().catch(() => false)) {
await menuItem.click();
await expect(frigateApp.page).toHaveURL(
new RegExp(target.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
}
await gear.click();
await frigateApp.page.getByLabel(target.label).click();
await expect(frigateApp.page).toHaveURL(target.url);
});
}
});
test("account button in sidebar is clickable (desktop)", async ({
test.describe("Navigation — mobile @critical @mobile", () => {
test("mobile bottombar visible, sidebar not rendered", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.goto("/");
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const items = sidebarBottom.locator("div[class*='cursor-pointer']");
const count = await items.count();
if (count >= 2) {
await items.nth(1).click();
await frigateApp.page.waitForTimeout(500);
await expect(frigateApp.page.locator("aside")).toHaveCount(0);
for (const route of PRIMARY_ROUTES) {
await expect(
frigateApp.page.locator(`a[href="${route}"]`).first(),
).toBeVisible();
}
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("mobile nav survives route change", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.goto("/");
const reviewLink = frigateApp.page.locator('a[href="/review"]').first();
await reviewLink.click();
await expect(frigateApp.page).toHaveURL(/\/review/);
await expect(
frigateApp.page.locator('a[href="/review"]').first(),
).toBeVisible();
});
});
-198
View File
@@ -1,198 +0,0 @@
/**
* PTZ overlay regression tests -- MEDIUM tier.
*
* Guards two things on the PTZ preset dropdown:
*
* 1. After selecting a preset, the "Presets" tooltip must not re-pop
* (focus-restore side-effect that originally prompted the
* `onCloseAutoFocus preventDefault` workaround).
* 2. Keyboard shortcuts fired after the dropdown closes should not
* re-open the dropdown via Space/Enter/Arrow on the trigger
* (PR #12079 — "Prevent ptz keyboard shortcuts from reopening
* presets menu").
*
* Requires an onvif-configured camera and a mocked /ptz/info endpoint
* exposing presets.
*
* TODO: migrate these tests into live.spec.ts when it comes out of
* PENDING_REWRITE in e2e/scripts/lint-specs.mjs. They live in a dedicated
* file today so they stay lint-compliant (no waitForTimeout, no
* conditional isVisible) while live.spec.ts is still exempt.
*/
import { test, expect } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
const PTZ_CAMERA = "front_door";
const PRESET_NAMES = ["home", "driveway", "front_porch"];
test.describe("PTZ preset dropdown @medium", () => {
test("selecting a preset closes menu cleanly and does not re-open on keyboard", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
// 1. Give front_door an onvif host so the PtzControlPanel renders.
// 2. Mock the /ptz/info endpoint to expose features + presets.
await frigateApp.api.install({
config: {
cameras: {
[PTZ_CAMERA]: {
onvif: {
host: "10.0.0.50",
},
},
},
},
});
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
route.fulfill({
json: {
name: PTZ_CAMERA,
features: ["pt", "zoom"],
presets: PRESET_NAMES,
profiles: [],
},
}),
);
// PTZ commands ride the WebSocket, not HTTP. The WsMocker intercepts
// the /ws route, so Playwright's page-level `websocket` event never
// fires — instead, patch the client WebSocket.prototype.send before
// any app code runs and mirror sends into a window-level array the
// test can read back.
await frigateApp.page.addInitScript(() => {
(window as unknown as { __sentWsFrames: string[] }).__sentWsFrames = [];
const origSend = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
try {
(
window as unknown as { __sentWsFrames: string[] }
).__sentWsFrames.push(typeof data === "string" ? data : "(binary)");
} catch {
// ignore — best-effort tracing
}
return origSend.call(this, data);
};
});
await frigateApp.goto(`/#${PTZ_CAMERA}`);
// Locate the preset trigger — a button whose accessible name includes
// "presets" (set via aria-label={t("ptz.presets")}).
const presetTrigger = frigateApp.page.getByRole("button", {
name: /presets/i,
});
await expect(presetTrigger.first()).toBeVisible({ timeout: 5_000 });
await presetTrigger.first().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
// Pick a preset.
const firstPreset = menu
.getByRole("menuitem", { name: PRESET_NAMES[0] })
.first();
await firstPreset.click();
// Menu closes.
await expect(menu).not.toBeVisible({ timeout: 3_000 });
// Preset command was dispatched over the WS.
await expect
.poll(
async () => {
const sentFrames = await frigateApp.page.evaluate(
() =>
(window as unknown as { __sentWsFrames: string[] })
.__sentWsFrames,
);
return sentFrames.some(
(frame) =>
frame.includes(`"${PTZ_CAMERA}/ptz"`) &&
frame.includes(`preset_${PRESET_NAMES[0]}`),
);
},
{ timeout: 2_000 },
)
.toBe(true);
// Body is interactive.
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
// Presets tooltip should NOT be visible.
await expect
.poll(
async () =>
frigateApp.page
.locator('[role="tooltip"]')
.filter({ hasText: /presets/i })
.isVisible()
.catch(() => false),
{ timeout: 1_000 },
)
.toBe(false);
// Now press keyboard keys — none should reopen the menu.
await frigateApp.page.keyboard.press("ArrowUp");
await frigateApp.page.keyboard.press("Space");
await frigateApp.page.keyboard.press("Enter");
await expect
.poll(() => menu.isVisible().catch(() => false), { timeout: 1_000 })
.toBe(false);
});
});
test.describe("Mobile live camera overlay @medium @mobile", () => {
test("mobile single-camera view loads without freezing body", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
// Same config override as the desktop spec so the mobile page exercises
// the onvif-enabled code path and its dismissable-layer consumers.
await frigateApp.api.install({
config: {
cameras: {
[PTZ_CAMERA]: {
onvif: { host: "10.0.0.50" },
},
},
},
});
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
route.fulfill({
json: {
name: PTZ_CAMERA,
features: ["pt", "zoom"],
presets: PRESET_NAMES,
profiles: [],
},
}),
);
await frigateApp.goto(`/#${PTZ_CAMERA}`);
// Body must be interactive after navigation — this is the mobile-side
// smoke test for the dismissable-layer dedupe. A regression that
// stuck pointer-events: none on <body> would make the rest of the UI
// unclickable.
await expectBodyInteractive(frigateApp.page);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});
@@ -1,301 +0,0 @@
/**
* Radix overlay regression tests -- MEDIUM tier.
*
* Guards the bug class fixed by de-duping `@radix-ui/react-dismissable-layer`:
*
* 1. Body `pointer-events: none` getting stuck after nested overlays close
* 2. Dropdown typeahead breaking on the second open
* 3. Tooltips popping after a dropdown closes (focus restore side-effect)
*
* These tests are grouped by UI path rather than by symptom, since a given
* flow usually exercises more than one failure mode.
*
* TODO: migrate these tests into the corresponding page specs
* (face-library.spec.ts, system.spec.ts, review.spec.ts) when those files
* come out of PENDING_REWRITE in e2e/scripts/lint-specs.mjs. They live in
* a dedicated file today so they stay lint-compliant (no waitForTimeout,
* no conditional isVisible) while the page specs are still exempt.
*/
import { type Locator } from "@playwright/test";
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
const GROUPED_FACE_EVENT_ID = "1775487131.3863528-abc123";
const GROUPED_FACE_TRAINING_IMAGES = [
`${GROUPED_FACE_EVENT_ID}-1775487131.3863528-unknown-0.95.webp`,
`${GROUPED_FACE_EVENT_ID}-1775487132.3863528-unknown-0.91.webp`,
];
async function installGroupedFaceAttemptData(app: FrigateApp) {
await app.api.install({
events: [
{
id: GROUPED_FACE_EVENT_ID,
label: "person",
sub_label: null,
camera: "front_door",
start_time: 1775487131.3863528,
end_time: 1775487161.3863528,
false_positive: false,
zones: ["front_yard"],
thumbnail: null,
has_clip: true,
has_snapshot: true,
retain_indefinitely: false,
plus_id: null,
model_hash: "abc123",
detector_type: "cpu",
model_type: "ssd",
data: {
top_score: 0.92,
score: 0.92,
region: [0.1, 0.1, 0.5, 0.8],
box: [0.2, 0.15, 0.45, 0.75],
area: 0.18,
ratio: 0.6,
type: "object",
path_data: [],
},
},
],
faces: {
train: GROUPED_FACE_TRAINING_IMAGES,
alice: ["alice-1.webp"],
bob: ["bob-1.webp"],
charlie: ["charlie-1.webp"],
david: ["david-1.webp"],
},
});
}
async function openGroupedFaceAttemptDialog(app: FrigateApp): Promise<Locator> {
await installGroupedFaceAttemptData(app);
await app.goto("/faces");
const groupedCardImage = app.page
.locator('img[src*="clips/faces/train/"]')
.first();
const groupedCard = groupedCardImage.locator("xpath=..");
await expect(groupedCardImage).toBeVisible({ timeout: 5_000 });
await groupedCard.click();
const dialog = app.page
.getByRole("dialog")
.filter({ has: app.page.locator('img[src*="clips/faces/train/"]') })
.first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.locator('img[src*="clips/faces/train/"]')).toHaveCount(2);
return dialog;
}
function groupedFaceReclassifyTriggers(dialog: Locator) {
return dialog.locator('[aria-haspopup="menu"]');
}
test.describe("FaceSelectionDialog @medium", () => {
test("grouped recent-recognition dialog closes menu without re-popping tooltip or locking body", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const dialog = await openGroupedFaceAttemptDialog(frigateApp);
const triggers = groupedFaceReclassifyTriggers(dialog);
await expect(triggers).toHaveCount(2);
await triggers.first().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await expect(dialog).toBeVisible();
// The grouped recent-recognitions flow wraps the dropdown trigger in a
// tooltip inside the detail dialog. Focus should not jump back there.
const visibleTooltip = await frigateApp.page
.locator('[role="tooltip"]')
.filter({ hasText: /train face/i })
.isVisible()
.catch(() => false);
expect(
visibleTooltip,
"Train Face tooltip popped after dropdown closed in grouped dialog — focus-restore regression",
).toBe(false);
});
test("second grouped-image dropdown open accepts typeahead keyboard input", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const dialog = await openGroupedFaceAttemptDialog(frigateApp);
const triggers = groupedFaceReclassifyTriggers(dialog);
await expect(triggers).toHaveCount(2);
await triggers.first().click();
let menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await expect(dialog).toBeVisible();
await triggers.nth(1).click();
menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await frigateApp.page.keyboard.press("c");
await expect
.poll(
async () =>
frigateApp.page.evaluate(
() =>
document.activeElement?.textContent?.trim().toLowerCase() ?? "",
),
{ timeout: 2_000 },
)
.toMatch(/^charlie/);
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible({ timeout: 3_000 });
});
});
test.describe("RestartDialog @medium", () => {
test("cancelling restart leaves body interactive", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// "Restart Frigate" lives in the sidebar GeneralSettings dropdown. The
// sidebar has several aria-haspopup triggers (System, Account, etc.);
// we open each until the Restart item is visible.
const sidebarTriggers = frigateApp.page
.locator('[role="complementary"] [aria-haspopup="menu"]')
.or(frigateApp.page.locator('aside [aria-haspopup="menu"]'));
const triggerCount = await sidebarTriggers.count();
expect(triggerCount).toBeGreaterThan(0);
let opened = false;
for (let i = 0; i < triggerCount; i++) {
const trigger = sidebarTriggers.nth(i);
await trigger.click().catch(() => {});
const restartItem = frigateApp.page
.getByRole("menuitem", { name: /restart/i })
.first();
const isVisible = await expect(restartItem)
.toBeVisible({ timeout: 300 })
.then(() => true)
.catch(() => false);
if (isVisible) {
await restartItem.click();
opened = true;
break;
}
await frigateApp.page.keyboard.press("Escape").catch(() => {});
}
expect(opened).toBe(true);
const cancel = frigateApp.page.getByRole("button", { name: /cancel/i });
await expect(cancel).toBeVisible({ timeout: 3_000 });
await cancel.click();
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
// Sanity: the surrounding shell is still clickable after the dialog closes.
const postCancelTrigger = sidebarTriggers.first();
await postCancelTrigger.click();
await expect(
frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first(),
).toBeVisible({ timeout: 3_000 });
});
});
test.describe("Nested overlay invariant @medium", () => {
test("closing review filter popover leaves body interactive", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/review");
const camerasBtn = frigateApp.page
.getByRole("button", { name: /cameras/i })
.first();
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
await camerasBtn.click();
const overlay = frigateApp.page
.locator(
'[role="menu"], [role="dialog"], [data-radix-popper-content-wrapper]',
)
.first();
await expect(overlay).toBeVisible({ timeout: 3_000 });
await frigateApp.page.keyboard.press("Escape");
await expect(overlay).not.toBeVisible({ timeout: 3_000 });
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
});
});
test.describe("Mobile face library overlay @medium @mobile", () => {
test("mobile library selector dropdown closes cleanly", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
// The library collection selector is a Radix DropdownMenu on both
// desktop and mobile — a direct consumer of react-dismissable-layer.
// This exercises the dedupe'd cleanup path on mobile viewport.
await installGroupedFaceAttemptData(frigateApp);
await frigateApp.goto("/faces");
const selector = frigateApp.page
.getByRole("button")
.filter({ hasText: /\(\d+\)/ })
.first();
await expect(selector).toBeVisible({ timeout: 5_000 });
await selector.click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
});
});
+292 -11
View File
@@ -1,23 +1,304 @@
/**
* Replay page tests -- LOW tier.
* Replay page tests -- MEDIUM tier.
*
* Tests replay page rendering and basic interactivity.
* /replay is the admin debug replay page (not a recordings player).
* Polls /api/debug_replay/status, renders a no-session state when
* inactive, and a live camera image + debug toggles + Stop controls
* when active.
*/
import { test, expect } from "../fixtures/frigate-test";
import {
activeSessionStatus,
noSessionStatus,
} from "../fixtures/mock-data/debug-replay";
test.describe("Replay Page @low", () => {
test("replay page renders without crash", async ({ frigateApp }) => {
async function installStatusRoute(
app: { page: import("@playwright/test").Page },
body: unknown,
) {
await app.page.route("**/api/debug_replay/status", (route) =>
route.fulfill({ json: body }),
);
}
test.describe("Replay — no active session @medium", () => {
test("empty state renders heading + Go to History button", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, noSessionStatus());
await frigateApp.goto("/replay");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /No Active Replay Session/i,
}),
).toBeVisible({ timeout: 10_000 });
const goButton = frigateApp.page.getByRole("button", {
name: /Go to History|Go to Recordings/i,
});
await expect(goButton).toBeVisible();
});
test("replay page has interactive controls", async ({ frigateApp }) => {
test("clicking Go to History navigates to /review", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, noSessionStatus());
await frigateApp.goto("/replay");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /No Active Replay Session/i,
}),
).toBeVisible({ timeout: 10_000 });
await frigateApp.page
.getByRole("button", { name: /Go to History|Go to Recordings/i })
.click();
await expect(frigateApp.page).toHaveURL(/\/review/);
});
});
test.describe("Replay — active session @medium", () => {
test("active status renders the Debug Replay side panel", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
).toBeVisible({ timeout: 10_000 });
// Three tabs (Debug / Objects / Messages) in TabsList
await expect(frigateApp.page.locator('[role="tab"]')).toHaveCount(3);
});
test("debug toggles render with bbox ON by default", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
const bbox = frigateApp.page.locator("#debug-bbox");
await expect(bbox).toBeVisible({ timeout: 10_000 });
await expect(bbox).toHaveAttribute("aria-checked", "true");
});
test("clicking bbox toggle flips aria-checked", async ({ frigateApp }) => {
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
const bbox = frigateApp.page.locator("#debug-bbox");
await expect(bbox).toBeVisible({ timeout: 10_000 });
await expect(bbox).toHaveAttribute("aria-checked", "true");
await bbox.click();
await expect(bbox).toHaveAttribute("aria-checked", "false");
});
test("Configuration button opens the configuration dialog (desktop)", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop: button has visible text label");
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
).toBeVisible({ timeout: 10_000 });
// On desktop the span is visible and gives the button an accessible name.
await frigateApp.page
.getByRole("button", { name: /configuration/i })
.first()
.click();
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
});
test("Configuration button opens the configuration dialog (mobile)", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile: button is icon-only");
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
).toBeVisible({ timeout: 10_000 });
// On mobile the Configuration button text span is hidden (md:inline).
// It is the first button inside the right-side action group div
// (the flex container that holds Config + Stop, sibling of the Back button).
const actionGroup = frigateApp.page.locator(
".flex.items-center.gap-2 button",
);
await actionGroup.first().click();
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
});
test("Objects tab renders with the camera_activity objects list", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
).toBeVisible({ timeout: 10_000 });
// Send an activity payload with a person object on front_door.
// Must be called after goto() so the WS connection is established.
await frigateApp.ws.sendCameraActivity({
front_door: {
objects: [
{
label: "person",
score: 0.95,
box: [0.1, 0.1, 0.5, 0.8],
area: 0.2,
ratio: 0.6,
region: [0.05, 0.05, 0.6, 0.85],
current_zones: [],
id: "obj-person-1",
},
],
},
});
// Switch to Objects tab (labelled "Object List" in i18n).
const objectsTab = frigateApp.page.getByRole("tab", {
name: /object/i,
});
await objectsTab.click();
await expect(objectsTab).toHaveAttribute("data-state", "active", {
timeout: 3_000,
});
// The object row renders the label.
await expect(frigateApp.page.getByText(/person/i).first()).toBeVisible({
timeout: 5_000,
});
});
test("Messages tab renders WsMessageFeed container", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
).toBeVisible({ timeout: 10_000 });
const messagesTab = frigateApp.page.getByRole("tab", {
name: /messages/i,
});
await messagesTab.click();
await expect(messagesTab).toHaveAttribute("data-state", "active", {
timeout: 3_000,
});
});
test("bbox info popover opens and closes cleanly", async ({ frigateApp }) => {
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
// The bbox row has an info icon popover trigger next to its label.
// The trigger is a div (not button) wrapping LuInfo with an sr-only
// "Info" span. Target it by the sr-only text content.
const infoTrigger = frigateApp.page
.locator("span.sr-only", { hasText: /info/i })
.first();
await expect(infoTrigger).toBeVisible({ timeout: 10_000 });
// Click the parent div (the actual trigger)
await infoTrigger.locator("..").click();
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
await frigateApp.page.keyboard.press("Escape");
await expect(popover.first()).not.toBeVisible({ timeout: 3_000 });
});
});
test.describe("Replay — stop flow (desktop) @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Desktop button has accessible 'Stop Replay' name",
);
test("Stop Replay opens confirm dialog; confirm POSTs debug_replay/stop", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, activeSessionStatus());
let stopCalled = false;
await frigateApp.page.route("**/api/debug_replay/stop", async (route) => {
if (route.request().method() === "POST") stopCalled = true;
await route.fulfill({ json: { success: true } });
});
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
).toBeVisible({ timeout: 10_000 });
await frigateApp.page
.getByRole("button", { name: /stop replay/i })
.first()
.click();
const dialog = frigateApp.page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 3_000 });
await dialog
.getByRole("button", { name: /stop|confirm/i })
.first()
.click();
await expect.poll(() => stopCalled, { timeout: 5_000 }).toBe(true);
});
});
test.describe("Replay — stop button (mobile) @medium @mobile", () => {
test.skip(
({ frigateApp }) => !frigateApp.isMobile,
"Mobile-only icon-button variant",
);
test("tapping the icon-only stop button opens the confirm dialog", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, activeSessionStatus());
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
).toBeVisible({ timeout: 10_000 });
// On mobile the Stop button is an icon (LuSquare) inside an
// AlertDialogTrigger. It's the last button in the top bar's
// right-side action group (Back is on the left). Target by
// position within the top-bar flex container.
const topRightButtons = frigateApp.page
.locator(".min-h-12 button, .md\\:min-h-16 button")
.filter({ hasNot: frigateApp.page.getByLabel("Back") });
const lastButton = topRightButtons.last();
await expect(lastButton).toBeVisible({ timeout: 10_000 });
await lastButton.click();
const dialog = frigateApp.page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 3_000 });
await dialog.getByRole("button", { name: /cancel/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
});
});
test.describe("Replay — mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("no-session state renders at mobile viewport", async ({
frigateApp,
}) => {
await installStatusRoute(frigateApp, noSessionStatus());
await frigateApp.goto("/replay");
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /No Active Replay Session/i,
}),
).toBeVisible({ timeout: 10_000 });
});
});
+184 -154
View File
@@ -1,200 +1,230 @@
/**
* Review/Events page tests -- CRITICAL tier.
*
* Tests severity tab switching by name (Alerts/Detections/Motion),
* filter popover opening with camera names, show reviewed toggle,
* calendar button, and filter button interactions.
* Severity tabs, filter popovers, calendar, show-reviewed toggle,
* timeline, and the nested-overlay regression migrated from
* radix-overlay-regressions.spec.ts.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
import { ReviewPage } from "../pages/review.page";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
test.describe("Review Page - Severity Tabs @critical", () => {
test("severity tabs render with Alerts, Detections, Motion", async ({
test.describe("Review — severity tabs @critical", () => {
test("tabs render with Alerts default-on", async ({ frigateApp }) => {
await frigateApp.goto("/review");
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
await expect(review.detectionsTab).toBeVisible();
await expect(review.motionTab).toBeVisible();
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
});
test("clicking Detections flips data-state", async ({ frigateApp }) => {
await frigateApp.goto("/review");
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
await review.detectionsTab.click();
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
});
test("clicking Motion flips data-state", async ({ frigateApp }) => {
await frigateApp.goto("/review");
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
await review.motionTab.click();
await expect(review.motionTab).toHaveAttribute("data-state", "on");
});
test("switching back to Alerts works", async ({ frigateApp }) => {
await frigateApp.goto("/review");
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
await review.detectionsTab.click();
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
await review.alertsTab.click();
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
});
test("switching tabs updates active data-state (client-side filter)", async ({
frigateApp,
}) => {
// The severity tabs filter the already-fetched review data client-side;
// they do not trigger a new /api/review network request. This test
// verifies the state-change assertion that the tab switch takes effect.
await frigateApp.goto("/review");
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
await review.detectionsTab.click();
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
});
await expect(frigateApp.page.getByLabel("Detections")).toBeVisible();
// Motion uses role="radio" to distinguish from other Motion elements
await expect(
frigateApp.page.getByRole("radio", { name: "Motion" }),
).toBeVisible();
});
test("Alerts tab is active by default", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const alertsTab = frigateApp.page.getByLabel("Alerts");
await expect(alertsTab).toHaveAttribute("data-state", "on");
});
test("clicking Detections tab makes it active and deactivates Alerts", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const alertsTab = frigateApp.page.getByLabel("Alerts");
const detectionsTab = frigateApp.page.getByLabel("Detections");
await detectionsTab.click();
await frigateApp.page.waitForTimeout(500);
await expect(detectionsTab).toHaveAttribute("data-state", "on");
await expect(alertsTab).toHaveAttribute("data-state", "off");
});
test("clicking Motion tab makes it active", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" });
await motionTab.click();
await frigateApp.page.waitForTimeout(500);
await expect(motionTab).toHaveAttribute("data-state", "on");
});
test("switching back to Alerts from Detections works", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
await frigateApp.page.getByLabel("Detections").click();
await frigateApp.page.waitForTimeout(300);
await frigateApp.page.getByLabel("Alerts").click();
await frigateApp.page.waitForTimeout(300);
await expect(frigateApp.page.getByLabel("Alerts")).toHaveAttribute(
"data-state",
"on",
test.describe("Review — filters (desktop) @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Filter bar differs on mobile",
);
});
});
test.describe("Review Page - Filters @critical", () => {
test("All Cameras filter button opens popover with camera names", async ({
test("Cameras popover lists configured camera names", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover should open with camera names from config
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Camera names should be present
const review = new ReviewPage(frigateApp.page, true);
await expect(review.camerasFilterTrigger).toBeVisible({ timeout: 5_000 });
await review.camerasFilterTrigger.click();
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
});
test("Show Reviewed toggle is clickable", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const showReviewed = frigateApp.page.getByRole("button", {
name: /reviewed/i,
});
if (await showReviewed.isVisible().catch(() => false)) {
await showReviewed.click();
await frigateApp.page.waitForTimeout(500);
// Toggle should change state
await expect(frigateApp.page.locator("body")).toBeVisible();
}
});
test("Last 24 Hours calendar button opens date picker", async ({
test("closing the Cameras popover with Escape leaves body interactive", async ({
frigateApp,
}) => {
// Migrated from radix-overlay-regressions.spec.ts.
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const calendarBtn = frigateApp.page.getByRole("button", {
name: /24 hours|calendar|date/i,
});
if (await calendarBtn.isVisible().catch(() => false)) {
await calendarBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover should open
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
if (
await popover
.first()
.isVisible()
.catch(() => false)
) {
const review = new ReviewPage(frigateApp.page, true);
await review.camerasFilterTrigger.click();
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
await frigateApp.page.keyboard.press("Escape");
}
}
await expect(review.filterOverlay).not.toBeVisible({ timeout: 3_000 });
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
});
test("Filter button opens filter popover", async ({ frigateApp }) => {
test("Labels are shown inside the General Filter dialog", async ({
frigateApp,
}) => {
// Labels are surfaced inside the "Filter" button's GeneralFilterContent
// dialog, not as a standalone top-level button. We open that dialog and
// confirm labels from the camera config are listed there.
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const filterBtn = frigateApp.page.getByRole("button", {
name: /^filter$/i,
});
if (await filterBtn.isVisible().catch(() => false)) {
const filterBtn = frigateApp.page
.getByRole("button", { name: /^filter$/i })
.first();
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
await filterBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover or dialog should open
const popover = frigateApp.page.locator(
const overlay = frigateApp.page.locator(
"[data-radix-popper-content-wrapper], [role='dialog']",
);
if (
await popover
.first()
.isVisible()
.catch(() => false)
) {
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
// The default mock config for front_door tracks "person"
await expect(overlay.first().getByText(/person/i)).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
}
}
});
});
test.describe("Review Page - Timeline @critical", () => {
test("review page has timeline with time markers (desktop)", async ({
test("Zones popover lists configured zones inside the General Filter dialog", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
// Override config to guarantee a known zone on front_door.
await frigateApp.installDefaults({
config: {
cameras: {
front_door: {
zones: {
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
},
},
},
},
});
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(2000);
// Timeline renders time labels like "4:30 PM"
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText).toMatch(/[AP]M/);
const filterBtn = frigateApp.page
.getByRole("button", { name: /^filter$/i })
.first();
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
await filterBtn.click();
const overlay = frigateApp.page.locator(
"[data-radix-popper-content-wrapper], [role='dialog']",
);
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
await expect(overlay.first().getByText(/front.?yard/i)).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
});
test("Calendar trigger opens a date picker popover", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
const review = new ReviewPage(frigateApp.page, true);
await expect(review.calendarTrigger).toBeVisible({ timeout: 5_000 });
await review.calendarTrigger.click();
// react-day-picker v9 renders a role="grid" calendar with day cells
// as buttons inside gridcells (e.g. "Wednesday, April 1st, 2026").
// The calendar is placed directly in the DOM (not always inside a
// Radix popper wrapper), so scope by the grid role instead.
const calendarGrid = frigateApp.page.locator('[role="grid"]').first();
await expect(calendarGrid).toBeVisible({ timeout: 3_000 });
const dayButton = calendarGrid.locator('[role="gridcell"] button').first();
await expect(dayButton).toBeVisible({ timeout: 3_000 });
await frigateApp.page.keyboard.press("Escape");
});
test("Show Reviewed switch flips its checked state", async ({
frigateApp,
}) => {
// "Show Reviewed" is a Radix Switch (role=switch), not a button.
// It filters review data client-side; it does not trigger a new
// /api/review network request. Verify the switch state toggles.
await frigateApp.goto("/review");
const showReviewedSwitch = frigateApp.page.getByRole("switch", {
name: /show reviewed/i,
});
await expect(showReviewedSwitch).toBeVisible({ timeout: 5_000 });
// Record initial checked state and click to toggle
const initialChecked =
await showReviewedSwitch.getAttribute("aria-checked");
await showReviewedSwitch.click();
const flippedChecked = initialChecked === "true" ? "false" : "true";
await expect(showReviewedSwitch).toHaveAttribute(
"aria-checked",
flippedChecked,
);
});
});
test.describe("Review Page - Navigation @critical", () => {
test("navigate to review from live page works", async ({ frigateApp }) => {
test.describe("Review — timeline (desktop) @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Timeline not shown on mobile",
);
test("timeline renders time markers", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await expect
.poll(
async () => (await frigateApp.page.textContent("#pageRoot")) ?? "",
{ timeout: 10_000 },
)
.toMatch(/[AP]M|\d+:\d+/);
});
});
test.describe("Review — mobile @critical @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("severity tabs render on mobile", async ({ frigateApp }) => {
await frigateApp.goto("/review");
const review = new ReviewPage(frigateApp.page, false);
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
await expect(review.detectionsTab).toBeVisible();
});
test("back navigation returns to Live", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
const base = new BasePage(frigateApp.page, false);
await base.navigateTo("/review");
await expect(frigateApp.page).toHaveURL(/\/review/);
// Severity tabs should be visible
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
});
await base.navigateTo("/");
await expect(frigateApp.page).toHaveURL(/\/$/);
});
});
+175 -28
View File
@@ -1,15 +1,21 @@
/**
* System page tests -- MEDIUM tier.
* System page tests -- MEDIUM tier (promoted to cover migrated
* RestartDialog test from radix-overlay-regressions.spec.ts).
*
* Tests system page rendering with tabs and tab switching.
* Navigates to /system#general explicitly so useHashState resolves
* the tab state deterministically.
* Tab switching, version + last-refreshed display, and the
* RestartDialog cancel flow.
*/
import { test, expect } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
test.describe("System Page @medium", () => {
test("system page renders with tab buttons", async ({ frigateApp }) => {
test.describe("System — tabs @medium", () => {
test("general tab is active by default via #general hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
@@ -20,7 +26,7 @@ test.describe("System Page @medium", () => {
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
});
test("general tab is active when navigated via hash", async ({
test("Storage tab activates and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
@@ -29,18 +35,6 @@ test.describe("System Page @medium", () => {
"on",
{ timeout: 15_000 },
);
});
test("clicking Storage tab activates it and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
@@ -53,29 +47,22 @@ test.describe("System Page @medium", () => {
);
});
test("clicking Cameras tab activates it and deactivates General", async ({
frigateApp,
}) => {
test("Cameras tab activates", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"off",
);
});
test("system page shows version and last refreshed", async ({
test("general tab shows version and last-refreshed", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
@@ -87,4 +74,164 @@ test.describe("System Page @medium", () => {
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
});
test("storage tab renders content after switching", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
// On desktop, tab buttons render text labels so the word "storage"
// always appears in #pageRoot after switching. On mobile, tabs are
// icon-only, so we verify the general-tab content disappears instead
// (the storage tab's metrics section is hidden but general is gone).
if (!frigateApp.isMobile) {
await expect
.poll(
async () => (await frigateApp.page.textContent("#pageRoot")) ?? "",
{ timeout: 10_000 },
)
.toMatch(/storage|mount|disk|used|free/i);
} else {
// Mobile: tab activation (data-state "on") already asserted above.
// Additionally confirm general tab is no longer the active tab.
await expect(
frigateApp.page.getByLabel("Select general"),
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
}
});
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
// Cameras tab lists every camera from config/stats. The default
// mock has front_door, backyard, garage.
for (const cam of ["front_door", "backyard", "garage"]) {
await expect(
frigateApp.page
.getByText(new RegExp(cam.replace("_", ".?"), "i"))
.first(),
).toBeVisible({ timeout: 10_000 });
}
});
test("enrichments tab renders when semantic search is enabled", async ({
frigateApp,
}) => {
// Override config to guarantee the enrichments tab is present.
// System.tsx shows the tab when semantic_search.enabled === true.
await frigateApp.installDefaults({
config: { semantic_search: { enabled: true } },
});
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
await enrichTab.click();
await expect(enrichTab).toHaveAttribute("data-state", "on", {
timeout: 5_000,
});
});
});
test.describe("System — RestartDialog @medium", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Sidebar menu is desktop-only",
);
test("cancelling restart leaves body interactive", async ({ frigateApp }) => {
// Migrated from radix-overlay-regressions.spec.ts.
await frigateApp.goto("/");
const sidebarTriggers = frigateApp.page
.locator('[role="complementary"] [aria-haspopup="menu"]')
.or(frigateApp.page.locator('aside [aria-haspopup="menu"]'));
const triggerCount = await sidebarTriggers.count();
expect(triggerCount).toBeGreaterThan(0);
let opened = false;
for (let i = 0; i < triggerCount; i++) {
const trigger = sidebarTriggers.nth(i);
await trigger.click().catch(() => {});
const restartItem = frigateApp.page
.getByRole("menuitem", { name: /restart/i })
.first();
const visible = await expect(restartItem)
.toBeVisible({ timeout: 300 })
.then(() => true)
.catch(() => false);
if (visible) {
await restartItem.click();
opened = true;
break;
}
await frigateApp.page.keyboard.press("Escape").catch(() => {});
}
expect(opened).toBe(true);
const cancel = frigateApp.page.getByRole("button", { name: /cancel/i });
await expect(cancel).toBeVisible({ timeout: 3_000 });
await cancel.click();
await waitForBodyInteractive(frigateApp.page);
await expectBodyInteractive(frigateApp.page);
const postCancelTrigger = sidebarTriggers.first();
await postCancelTrigger.click();
await expect(
frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first(),
).toBeVisible({ timeout: 3_000 });
});
});
test.describe("System — mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("tabs render at mobile viewport", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 15_000,
});
});
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
});
});