diff --git a/web/e2e/fixtures/mock-data/debug-replay.ts b/web/e2e/fixtures/mock-data/debug-replay.ts new file mode 100644 index 000000000..9b9d2c650 --- /dev/null +++ b/web/e2e/fixtures/mock-data/debug-replay.ts @@ -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, + }; +} diff --git a/web/e2e/fixtures/mock-data/faces.ts b/web/e2e/fixtures/mock-data/faces.ts new file mode 100644 index 000000000..ed2f944cb --- /dev/null +++ b/web/e2e/fixtures/mock-data/faces.ts @@ -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; + +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], + }; +} diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index 52f10d64b..e1a191fe0 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -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) => diff --git a/web/e2e/helpers/clipboard.ts b/web/e2e/helpers/clipboard.ts new file mode 100644 index 000000000..9099073b3 --- /dev/null +++ b/web/e2e/helpers/clipboard.ts @@ -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 { + 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 { + return page.evaluate(async () => await navigator.clipboard.readText()); +} diff --git a/web/e2e/helpers/monaco.ts b/web/e2e/helpers/monaco.ts new file mode 100644 index 000000000..6e9bcd871 --- /dev/null +++ b/web/e2e/helpers/monaco.ts @@ -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 { + 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 { + 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 { + 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 { + await expect + .poll(() => hasErrorMarkers(page), { timeout: timeoutMs }) + .toBe(true); +} diff --git a/web/e2e/helpers/ws-frames.ts b/web/e2e/helpers/ws-frames.ts new file mode 100644 index 000000000..d46376d87 --- /dev/null +++ b/web/e2e/helpers/ws-frames.ts @@ -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 { + 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 { + 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 { + const { timeout = 2_000, message } = opts; + await expect + .poll(async () => (await readWsFrames(page)).some(matcher), { + timeout, + message, + }) + .toBe(true); +} diff --git a/web/e2e/helpers/ws-mocker.ts b/web/e2e/helpers/ws-mocker.ts index 6b29b7639..03db9f741 100644 --- a/web/e2e/helpers/ws-mocker.ts +++ b/web/e2e/helpers/ws-mocker.ts @@ -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)); diff --git a/web/e2e/pages/live.page.ts b/web/e2e/pages/live.page.ts new file mode 100644 index 000000000..814064944 --- /dev/null +++ b/web/e2e/pages/live.page.ts @@ -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 { + await this.cameraCard(cameraName).first().click({ button: "right" }); + return this.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + } +} diff --git a/web/e2e/pages/review.page.ts b/web/e2e/pages/review.page.ts new file mode 100644 index 000000000..6d9cfb7c2 --- /dev/null +++ b/web/e2e/pages/review.page.ts @@ -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(); + } +} diff --git a/web/e2e/scripts/lint-specs.mjs b/web/e2e/scripts/lint-specs.mjs index 4724e99bb..e4046869d 100644 --- a/web/e2e/scripts/lint-specs.mjs +++ b/web/e2e/scripts/lint-specs.mjs @@ -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 = []; diff --git a/web/e2e/specs/auth.spec.ts b/web/e2e/specs/auth.spec.ts index 5f5837518..f0326a5d0 100644 --- a/web/e2e/specs/auth.spec.ts +++ b/web/e2e/specs/auth.spec.ts @@ -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("/"); - await expect( - frigateApp.page.locator('a[href="/classification"]'), - ).toBeVisible(); - }); }); -test.describe("Auth - Viewer Restrictions @high", () => { - test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => { - 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.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.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("viewer sees Access Denied on /config", async ({ frigateApp, page }) => { + test("viewer sees severity tabs on /review", async ({ frigateApp }) => { 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, - }); - }); }); diff --git a/web/e2e/specs/chat.spec.ts b/web/e2e/specs/chat.spec.ts index ba4a4e658..fcd10782c 100644 --- a/web/e2e/specs/chat.spec.ts +++ b/web/e2e/specs/chat.spec.ts @@ -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>, + 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, + }); + } + 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("chat page has interactive input or buttons", async ({ frigateApp }) => { +test.describe("Chat — starting state @medium", () => { + test("empty message list renders ChatStartingState with title and input", 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); - } + 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(); }); }); diff --git a/web/e2e/specs/classification.spec.ts b/web/e2e/specs/classification.spec.ts index 9dd0815c6..83a33a815 100644 --- a/web/e2e/specs/classification.spec.ts +++ b/web/e2e/specs/classification.spec.ts @@ -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 = { 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, + }); }); }); diff --git a/web/e2e/specs/config-editor.spec.ts b/web/e2e/specs/config-editor.spec.ts index 1de6fc52b..2b51a9363 100644 --- a/web/e2e/specs/config-editor.spec.ts +++ b/web/e2e/specs/config-editor.spec.ts @@ -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 ({ - frigateApp, - }) => { +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, +): 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 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 }, + ); + // 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 expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_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("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 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.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 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, + 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 }, ); - await expect(editor.first()).toBeVisible({ timeout: 10_000 }); - }); - - test("config editor has action buttons", async ({ frigateApp }) => { - await frigateApp.goto("/config"); - await frigateApp.page.waitForTimeout(5000); - const buttons = frigateApp.page.locator("button"); - const count = await buttons.count(); - expect(count).toBeGreaterThan(0); - }); - - test("config editor button clicks do not crash", async ({ frigateApp }) => { - 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(); }); }); diff --git a/web/e2e/specs/explore.spec.ts b/web/e2e/specs/explore.spec.ts index 1811338a4..9d239a3f9 100644 --- a/web/e2e/specs/explore.spec.ts +++ b/web/e2e/specs/explore.spec.ts @@ -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=. 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 }) => { +// 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; + +// --------------------------------------------------------------------------- +// 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"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill("person"); + await expect(searchInput).toHaveValue("person"); + await searchInput.fill(""); + await expect(searchInput).toHaveValue(""); + }); + + test("Enter submission does not crash the page", 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.fill("car in driveway"); + await searchInput.press("Enter"); await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - const buttons = frigateApp.page.locator("#pageRoot button"); - await expect(buttons.first()).toBeVisible({ timeout: 10_000 }); - }); - - test("search input accepts text and can be cleared", async ({ - frigateApp, - }) => { - await frigateApp.goto("/explore"); - await frigateApp.page.waitForTimeout(1000); - const searchInput = frigateApp.page.locator("input").first(); - if (await searchInput.isVisible()) { - await searchInput.fill("person"); - await expect(searchInput).toHaveValue("person"); - await searchInput.fill(""); - await expect(searchInput).toHaveValue(""); - } - }); - - test("search input submits on Enter", async ({ frigateApp }) => { - await frigateApp.goto("/explore"); - await frigateApp.page.waitForTimeout(1000); - const searchInput = frigateApp.page.locator("input").first(); - if (await searchInput.isVisible()) { - 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)) { - await camerasBtn.click(); - await frigateApp.page.waitForTimeout(500); - 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"); - } +// --------------------------------------------------------------------------- +// 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(); + // 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 }); + await expect(frigateApp.page.getByText("Front Door")).toBeVisible(); }); - test("filter button opens and closes overlay cleanly", 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); + 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, + }) => { + // 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= (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(); }); }); diff --git a/web/e2e/specs/face-library.spec.ts b/web/e2e/specs/face-library.spec.ts index d68b8f8a5..ca21642bd 100644 --- a/web/e2e/specs/face-library.spec.ts +++ b/web/e2e/specs/face-library.spec.ts @@ -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 { + 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 { + // 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 ({ - frigateApp, - }) => { + test("tiles render for each named collection", async ({ frigateApp }) => { + await frigateApp.installDefaults({ faces: basicFacesMock() }); 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); - }); - - test("face library has interactive buttons", async ({ frigateApp }) => { - 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 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//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"); + + // 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.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//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"); + + // 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); }); }); diff --git a/web/e2e/specs/live.spec.ts b/web/e2e/specs/live.spec.ts index e355984b3..af28f0030 100644 --- a/web/e2e/specs/live.spec.ts +++ b/web/e2e/specs/live.spec.ts @@ -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 //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 //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/); - }); -}); diff --git a/web/e2e/specs/logs.spec.ts b/web/e2e/specs/logs.spec.ts index 1f6af36ae..0b74acfa1 100644 --- a/web/e2e/specs/logs.spec.ts +++ b/web/e2e/specs/logs.spec.ts @@ -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/ 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/?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); - }); }); diff --git a/web/e2e/specs/navigation.spec.ts b/web/e2e/specs/navigation.spec.ts index e049b6f7e..592247186 100644 --- a/web/e2e/specs/navigation.spec.ts +++ b/web/e2e/specs/navigation.spec.ts @@ -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" }, - ]; - for (const target of targets) { + 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) { + 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.describe("Navigation — mobile @critical @mobile", () => { + test("mobile bottombar visible, sidebar not rendered", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); + await frigateApp.goto("/"); + await expect(frigateApp.page.locator("aside")).toHaveCount(0); + for (const route of PRIMARY_ROUTES) { + await expect( + frigateApp.page.locator(`a[href="${route}"]`).first(), + ).toBeVisible(); } }); - test("account button in sidebar is clickable (desktop)", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + test("mobile nav survives route change", async ({ frigateApp }) => { + 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("body")).toBeVisible(); + 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(); }); }); diff --git a/web/e2e/specs/ptz-overlay.spec.ts b/web/e2e/specs/ptz-overlay.spec.ts deleted file mode 100644 index 06beb1788..000000000 --- a/web/e2e/specs/ptz-overlay.spec.ts +++ /dev/null @@ -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 would make the rest of the UI - // unclickable. - await expectBodyInteractive(frigateApp.page); - await expect(frigateApp.page.locator("body")).toBeVisible(); - }); -}); diff --git a/web/e2e/specs/radix-overlay-regressions.spec.ts b/web/e2e/specs/radix-overlay-regressions.spec.ts deleted file mode 100644 index aab0be92f..000000000 --- a/web/e2e/specs/radix-overlay-regressions.spec.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/web/e2e/specs/replay.spec.ts b/web/e2e/specs/replay.spec.ts index c506fec5a..eb19ed57d 100644 --- a/web/e2e/specs/replay.spec.ts +++ b/web/e2e/specs/replay.spec.ts @@ -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 }); }); }); diff --git a/web/e2e/specs/review.spec.ts b/web/e2e/specs/review.spec.ts index 166f32c44..8d52e083a 100644 --- a/web/e2e/specs/review.spec.ts +++ b/web/e2e/specs/review.spec.ts @@ -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, - }); - 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", - ); + 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"); }); }); -test.describe("Review Page - Filters @critical", () => { - test("All Cameras filter button opens popover with camera names", async ({ +test.describe("Review — filters (desktop) @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Filter bar differs on mobile", + ); + + 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(); + }); + test("closing the Cameras popover with Escape leaves body interactive", async ({ + frigateApp, + }) => { + // Migrated from radix-overlay-regressions.spec.ts. + await frigateApp.goto("/review"); + 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("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"); + 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 }); + // The default mock config for front_door tracks "person" + await expect(overlay.first().getByText(/person/i)).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, + test("Zones popover lists configured zones inside the General Filter 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" }, + }, + }, + }, + }, }); - 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(); - } + await frigateApp.goto("/review"); + 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("Last 24 Hours calendar button opens date picker", async ({ + test("Calendar trigger opens a date picker popover", async ({ frigateApp, }) => { await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); + const review = new ReviewPage(frigateApp.page, true); + await expect(review.calendarTrigger).toBeVisible({ timeout: 5_000 }); + await review.calendarTrigger.click(); - 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) - ) { - await frigateApp.page.keyboard.press("Escape"); - } - } + // 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("Filter button opens filter popover", async ({ frigateApp }) => { - await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - - const filterBtn = frigateApp.page.getByRole("button", { - name: /^filter$/i, - }); - if (await filterBtn.isVisible().catch(() => false)) { - await filterBtn.click(); - await frigateApp.page.waitForTimeout(500); - // Popover or dialog should open - const popover = frigateApp.page.locator( - "[data-radix-popper-content-wrapper], [role='dialog']", - ); - if ( - await popover - .first() - .isVisible() - .catch(() => false) - ) { - await frigateApp.page.keyboard.press("Escape"); - } - } - }); -}); - -test.describe("Review Page - Timeline @critical", () => { - test("review page has timeline with time markers (desktop)", async ({ + test("Show Reviewed switch flips its checked state", async ({ frigateApp, }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + // "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"); - 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 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(/\/$/); }); }); diff --git a/web/e2e/specs/system.spec.ts b/web/e2e/specs/system.spec.ts index a3aa512e5..87e4db51e 100644 --- a/web/e2e/specs/system.spec.ts +++ b/web/e2e/specs/system.spec.ts @@ -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 }, + ); + }); });