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