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: [] }),
|
route.fulfill({ json: [] }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sub-labels and attributes (for explore filters)
|
// Sub-labels and attributes (for explore filters).
|
||||||
await this.page.route("**/api/sub_labels", (route) =>
|
// Use trailing ** so query-string variants (e.g. ?split_joined=1) match.
|
||||||
|
await this.page.route("**/api/sub_labels**", (route) =>
|
||||||
route.fulfill({ json: [] }),
|
route.fulfill({ json: [] }),
|
||||||
);
|
);
|
||||||
await this.page.route("**/api/labels", (route) =>
|
await this.page.route("**/api/labels**", (route) =>
|
||||||
route.fulfill({ json: ["person", "car"] }),
|
route.fulfill({ json: ["person", "car"] }),
|
||||||
);
|
);
|
||||||
await this.page.route("**/api/*/attributes", (route) =>
|
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({}));
|
this.send("model_state", JSON.stringify({}));
|
||||||
}
|
}
|
||||||
if (data.topic === "embeddingsReindexProgress") {
|
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") {
|
if (data.topic === "birdseyeLayout") {
|
||||||
this.send("birdseye_layout", JSON.stringify(null));
|
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
|
* @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must
|
||||||
* contain at least one test title or describe with the substring "@mobile".
|
* 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";
|
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 SPECS_DIR = resolve(__dirname, "..", "specs");
|
||||||
const META_PREFIX = resolve(SPECS_DIR, "_meta");
|
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 = [
|
const BANNED_PATTERNS = [
|
||||||
{
|
{
|
||||||
name: "page.waitForTimeout",
|
name: "page.waitForTimeout",
|
||||||
@@ -62,14 +40,12 @@ const BANNED_PATTERNS = [
|
|||||||
{
|
{
|
||||||
name: "conditional count() assertion",
|
name: "conditional count() assertion",
|
||||||
regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/,
|
regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/,
|
||||||
advice:
|
advice: "Assertions must be unconditional. Use expect(...).toHaveCount(n).",
|
||||||
"Assertions must be unconditional. Use expect(...).toHaveCount(n).",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "vacuous textContent length assertion",
|
name: "vacuous textContent length assertion",
|
||||||
regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/,
|
regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/,
|
||||||
advice:
|
advice: "Assert specific content, not that some text exists.",
|
||||||
"Assert specific content, not that some text exists.",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -89,8 +65,6 @@ function walk(dir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lintFile(file) {
|
function lintFile(file) {
|
||||||
const basename = file.split("/").pop();
|
|
||||||
if (PENDING_REWRITE.has(basename)) return [];
|
|
||||||
if (file.includes("/specs/settings/")) return [];
|
if (file.includes("/specs/settings/")) return [];
|
||||||
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|||||||
+64
-101
@@ -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,
|
* Admin access to /system, /config, /logs; viewer access denied
|
||||||
* access denied page rendering, viewer nav restrictions,
|
* markers (via i18n heading, not a data-testid we don't own);
|
||||||
* and all routes smoke test.
|
* viewer nav restrictions; all-routes smoke.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Auth - Admin Access @high", () => {
|
test.describe("Auth — admin access @high", () => {
|
||||||
test("admin can access /system and sees system tabs", async ({
|
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/system");
|
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({
|
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /config and Monaco editor loads", async ({
|
test("admin /config renders Monaco editor", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/config");
|
await frigateApp.goto("/config");
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
await expect(
|
||||||
const editor = frigateApp.page.locator(
|
frigateApp.page
|
||||||
".monaco-editor, [data-keybinding-context]",
|
.locator(".monaco-editor, [data-keybinding-context]")
|
||||||
);
|
.first(),
|
||||||
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /logs and sees service tabs", async ({
|
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/logs");
|
await frigateApp.goto("/logs");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
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.describe("Auth — viewer restrictions @high", () => {
|
||||||
test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => {
|
for (const path of ["/system", "/config", "/logs"]) {
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
test(`viewer on ${path} sees AccessDenied`, async ({ frigateApp }) => {
|
||||||
await page.goto("/system");
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
await page.waitForTimeout(2000);
|
await frigateApp.page.goto(path);
|
||||||
// Should show "Access Denied" text
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
await expect(page.getByText("Access Denied")).toBeVisible({
|
await expect(
|
||||||
timeout: 5_000,
|
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 frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
await page.goto("/config");
|
await frigateApp.page.goto("/review");
|
||||||
await page.waitForTimeout(2000);
|
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
||||||
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({
|
|
||||||
timeout: 10_000,
|
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,
|
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() });
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
await page.goto(route);
|
await frigateApp.page.goto(route);
|
||||||
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
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.describe("Auth — viewer nav restrictions (desktop) @high", () => {
|
||||||
test("all user routes render without crash", async ({ frigateApp }) => {
|
test.skip(({ frigateApp }) => frigateApp.isMobile, "Sidebar only on desktop");
|
||||||
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
|
||||||
for (const route of routes) {
|
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 frigateApp.goto(route);
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
timeout: 10_000,
|
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.
|
* 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 }) => {
|
* Install a window.fetch override on the page so that POSTs to
|
||||||
await frigateApp.goto("/chat");
|
* chat/completion resolve with a real ReadableStream that emits the
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
* given chunks over time. This is the only way to validate
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await new Promise((r) => setTimeout(r, chunkDelayMs));
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(JSON.stringify(chunk) + "\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(stream, { status: 200 });
|
||||||
|
}
|
||||||
|
return origFetch.call(window, input as RequestInfo, init);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ chunks, chunkDelayMs, status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
|
test.describe("Chat — starting state @medium", () => {
|
||||||
|
test("empty message list renders ChatStartingState with title and input", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
await frigateApp.goto("/chat");
|
await frigateApp.goto("/chat");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
const interactive = frigateApp.page.locator("input, textarea, button");
|
frigateApp.page.getByRole("heading", { level: 1 }),
|
||||||
const count = await interactive.count();
|
).toBeVisible({ timeout: 10_000 });
|
||||||
expect(count).toBeGreaterThan(0);
|
await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible();
|
||||||
});
|
// Four quick-reply buttons from starting_requests.*
|
||||||
|
const quickReplies = frigateApp.page.locator(
|
||||||
test("chat input accepts text", async ({ frigateApp }) => {
|
"button:has-text('Show recent events'), button:has-text('Show camera status'), button:has-text('What happened'), button:has-text('Watch')",
|
||||||
await frigateApp.goto("/chat");
|
);
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(quickReplies.first()).toBeVisible({ timeout: 5_000 });
|
||||||
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();
|
test.describe("Chat — streaming @medium", () => {
|
||||||
expect(value.length).toBeGreaterThan(0);
|
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.
|
* 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 { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Classification @medium", () => {
|
const CUSTOM_MODELS = {
|
||||||
test("classification page renders without crash", async ({ frigateApp }) => {
|
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 frigateApp.goto("/classification");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
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 ({
|
test("empty custom map renders without crash", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
await frigateApp.installDefaults({
|
||||||
}) => {
|
config: { classification: { custom: {} } },
|
||||||
|
});
|
||||||
await frigateApp.goto("/classification");
|
await frigateApp.goto("/classification");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
timeout: 10_000,
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("classification page has interactive elements", async ({
|
test("toggling to states view switches the rendered card set", async ({
|
||||||
frigateApp,
|
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.goto("/classification");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
// Objects is default — object_classifier visible, state_classifier hidden.
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
|
||||||
const count = await buttons.count();
|
timeout: 10_000,
|
||||||
expect(count).toBeGreaterThanOrEqual(0);
|
});
|
||||||
|
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,
|
* Monaco load + value, Save (config/save?save_option=saveonly),
|
||||||
* save button presence, and copy button interaction.
|
* Save error path, Save and Restart (WS frame via useRestart),
|
||||||
|
* Copy (clipboard), schema markers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
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", () => {
|
const SAMPLE_CONFIG =
|
||||||
test("config editor loads Monaco editor with content", async ({
|
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n";
|
||||||
frigateApp,
|
|
||||||
}) => {
|
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.goto("/config");
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||||
// Monaco editor should render with a specific class
|
{ timeout: 15_000 },
|
||||||
const editor = frigateApp.page.locator(
|
);
|
||||||
".monaco-editor, [data-keybinding-context]",
|
// 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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+242
-74
@@ -1,97 +1,265 @@
|
|||||||
/**
|
/**
|
||||||
* Explore page tests -- HIGH tier.
|
* Explore page tests -- HIGH tier.
|
||||||
*
|
*
|
||||||
* Tests search input with text entry and clearing, camera filter popover
|
* Search input, Enter submission, camera filter popover (desktop),
|
||||||
* opening with camera names, and content rendering with mock events.
|
* 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";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
|
||||||
test.describe("Explore Page - Search @high", () => {
|
// Semantic search config override used by multiple tests. Using model:
|
||||||
test("explore page renders with filter buttons", async ({ frigateApp }) => {
|
// "genai" (not in JINA_EMBEDDING_MODELS) sets isGenaiEmbeddings=true, which
|
||||||
|
// skips local model-state checks and lets the page render without waiting for
|
||||||
|
// individual model download WS messages. The WS mocker returns a completed
|
||||||
|
// reindexState so !reindexState is false and the loading gate clears.
|
||||||
|
const SEMANTIC_SEARCH_CONFIG = {
|
||||||
|
semantic_search: { enabled: true, model: "genai" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Search input (semantic_search must be enabled)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe("Explore — search @high", () => {
|
||||||
|
test("search input accepts text and clears", async ({ frigateApp }) => {
|
||||||
|
// Enable semantic search so InputWithTags renders.
|
||||||
|
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||||
await frigateApp.goto("/explore");
|
await frigateApp.goto("/explore");
|
||||||
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
await searchInput.fill("person");
|
||||||
|
await expect(searchInput).toHaveValue("person");
|
||||||
|
await searchInput.fill("");
|
||||||
|
await expect(searchInput).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Enter submission does not crash the page", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||||
|
await frigateApp.goto("/explore");
|
||||||
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
await searchInput.fill("car in driveway");
|
||||||
|
await searchInput.press("Enter");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
|
||||||
await expect(buttons.first()).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("search input accepts text and can be cleared", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/explore");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const searchInput = frigateApp.page.locator("input").first();
|
|
||||||
if (await searchInput.isVisible()) {
|
|
||||||
await searchInput.fill("person");
|
|
||||||
await expect(searchInput).toHaveValue("person");
|
|
||||||
await searchInput.fill("");
|
|
||||||
await expect(searchInput).toHaveValue("");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("search input submits on Enter", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/explore");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const searchInput = frigateApp.page.locator("input").first();
|
|
||||||
if (await searchInput.isVisible()) {
|
|
||||||
await searchInput.fill("car in driveway");
|
|
||||||
await searchInput.press("Enter");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
// Page should not crash after search submit
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Explore Page - Filters @high", () => {
|
// ---------------------------------------------------------------------------
|
||||||
test("camera filter button opens popover with camera names (desktop)", async ({
|
// Filter bar — desktop only
|
||||||
frigateApp,
|
// Filter buttons appear once hasExistingSearch is true (URL params present).
|
||||||
}) => {
|
// ---------------------------------------------------------------------------
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
test.describe("Explore — filters (desktop) @high", () => {
|
||||||
return;
|
test.skip(({ frigateApp }) => frigateApp.isMobile, "Desktop popovers");
|
||||||
}
|
|
||||||
await frigateApp.goto("/explore");
|
test("Cameras popover lists configured cameras", async ({ frigateApp }) => {
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
// Navigate with a labels filter param so the filter bar renders.
|
||||||
const camerasBtn = frigateApp.page.getByRole("button", {
|
await frigateApp.goto("/explore?labels=person");
|
||||||
name: /cameras/i,
|
// CamerasFilterButton has aria-label="Cameras Filter". Use getByLabel to
|
||||||
});
|
// match against the accessible name (not the inner "All Cameras" text).
|
||||||
if (await camerasBtn.isVisible().catch(() => false)) {
|
const camerasBtn = frigateApp.page.getByLabel("Cameras Filter").first();
|
||||||
await camerasBtn.click();
|
await expect(camerasBtn).toBeVisible({ timeout: 10_000 });
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await camerasBtn.click();
|
||||||
const popover = frigateApp.page.locator(
|
// DropdownMenu on desktop wraps content in data-radix-popper-content-wrapper.
|
||||||
"[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(popover.first()).toBeVisible({ timeout: 3_000 });
|
||||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
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 }) => {
|
||||||
frigateApp,
|
// Navigate with an existing search so the filter bar renders.
|
||||||
}) => {
|
await frigateApp.goto("/explore?labels=person");
|
||||||
await frigateApp.goto("/explore");
|
// GeneralFilterButton has aria-label="Labels". With ?labels=person the
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
// button text shows "Person" (the selected label), but the aria-label
|
||||||
const firstButton = frigateApp.page.locator("#pageRoot button").first();
|
// remains "Labels".
|
||||||
await expect(firstButton).toBeVisible({ timeout: 5_000 });
|
const labelsBtn = frigateApp.page.getByLabel("Labels").first();
|
||||||
await firstButton.click();
|
await expect(labelsBtn).toBeVisible({ timeout: 10_000 });
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await labelsBtn.click();
|
||||||
|
// PlatformAwareDialog renders on desktop as a dropdown/popover overlay.
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
// "person" is already selected (it's in the URL); assert it appears in
|
||||||
|
// the overlay content.
|
||||||
|
await expect(overlay.first().getByText(/person/i)).toBeVisible();
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sub-labels filter renders inside More Filters dialog", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Sub-labels live inside SearchFilterDialog ("More Filters" button).
|
||||||
|
// With sub_labels mocked as [], the section still renders its heading.
|
||||||
|
await frigateApp.page.route("**/api/sub_labels**", (route) =>
|
||||||
|
route.fulfill({ json: [] }),
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/explore?labels=person");
|
||||||
|
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
|
||||||
|
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
|
||||||
|
await moreBtn.click();
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
// "Sub Labels" section heading always renders inside the dialog.
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText(/sub.?label/i).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Zones filter lists configured zones inside More Filters dialog", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Override config to guarantee a known zone on front_door.
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: {
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: {
|
||||||
|
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/explore?labels=person");
|
||||||
|
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
|
||||||
|
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
|
||||||
|
await moreBtn.click();
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(frigateApp.page.getByText(/front.?yard/i)).toBeVisible();
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
await frigateApp.page.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,
|
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.goto("/explore");
|
||||||
await frigateApp.page.waitForTimeout(3000);
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
expect(pageText?.length).toBeGreaterThan(0);
|
|
||||||
|
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", () => {
|
const GROUPED_EVENT_ID = "1775487131.3863528-abc123";
|
||||||
test("face library page renders without crash", async ({ frigateApp }) => {
|
|
||||||
|
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 frigateApp.goto("/faces");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator('img[src*="/clips/faces/"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("face library shows empty state with no faces", async ({
|
test("tiles render for each named collection", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/faces");
|
await frigateApp.goto("/faces");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
// Open the dropdown — collections list shows "alice (2)" and "bob (1)".
|
||||||
// With empty faces mock, should show empty state or content
|
const menu = await openLibraryDropdown(frigateApp);
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
await expect(
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
menu.locator('[role="menuitem"]').filter({ hasText: /alice/i }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(
|
||||||
test("face library has interactive buttons", async ({ frigateApp }) => {
|
menu.locator('[role="menuitem"]').filter({ hasText: /bob/i }).first(),
|
||||||
await frigateApp.goto("/faces");
|
).toBeVisible();
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
});
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
});
|
||||||
const count = await buttons.count();
|
|
||||||
expect(count).toBeGreaterThanOrEqual(0);
|
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.
|
* Live page tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests camera dashboard rendering, camera card clicks, single camera view
|
* Dashboard grid, single-camera controls, feature toggles (with WS
|
||||||
* with named controls, feature toggle behavior, context menu, and mobile layout.
|
* 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 { 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.describe("Live Dashboard @critical", () => {
|
||||||
test("dashboard renders all configured cameras by name", async ({
|
test("every configured camera renders on the dashboard", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
|
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||||
await expect(
|
await expect(live.cameraCard(cam)).toBeVisible({ timeout: 10_000 });
|
||||||
frigateApp.page.locator(`[data-camera='${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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
await card.click({ timeout: 10_000 });
|
await live.cameraCard("front_door").first().click({ timeout: 10_000 });
|
||||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back button returns from single camera to dashboard", async ({
|
test("birdseye route renders without crash", async ({ frigateApp }) => {
|
||||||
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 }) => {
|
|
||||||
await frigateApp.goto("/#birdseye");
|
await frigateApp.goto("/#birdseye");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("empty group shows fallback content", async ({ frigateApp }) => {
|
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.describe("Live Single Camera — desktop controls @critical", () => {
|
||||||
test("single camera view shows Back and History buttons (desktop)", async ({
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Desktop-only header controls",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("single-camera view shows Back and History buttons", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip(); // On mobile, buttons may show icons only
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Back and History are visible text buttons in the header
|
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||||
await expect(
|
await expect(live.historyButton).toBeVisible();
|
||||||
frigateApp.page.getByText("Back", { exact: true }),
|
|
||||||
).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("History", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single camera view shows feature toggle icons (desktop)", async ({
|
test("feature toggles render (at least 3)", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Feature toggles are CameraFeatureToggle components rendered as divs
|
// Wait for the single-camera header to render before counting toggles.
|
||||||
// with bg-selected (active) or bg-secondary (inactive) classes
|
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||||
// Count the toggles - should have at least detect, recording, snapshots
|
await expect(live.featureToggles.first()).toBeVisible({ timeout: 5_000 });
|
||||||
const toggles = frigateApp.page.locator(
|
const count = await live.featureToggles.count();
|
||||||
".flex.flex-col.items-center.justify-center.bg-selected, .flex.flex-col.items-center.justify-center.bg-secondary",
|
|
||||||
);
|
|
||||||
const count = await toggles.count();
|
|
||||||
expect(count).toBeGreaterThanOrEqual(3);
|
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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
await installWsFrameCapture(frigateApp.page);
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Find active toggles (bg-selected class = feature is ON)
|
// Wait for feature toggles to render (WS camera_activity must arrive first).
|
||||||
const activeToggles = frigateApp.page.locator(
|
await expect(live.activeFeatureToggles.first()).toBeVisible({
|
||||||
".flex.flex-col.items-center.justify-center.bg-selected",
|
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 ({
|
test("keyboard shortcut f does not crash", async ({ frigateApp }) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await frigateApp.page.keyboard.press("f");
|
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();
|
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.
|
* Logs page tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests service tab switching by name, copy/download buttons,
|
* Service tabs (with real /logs/<service> JSON contract),
|
||||||
* and websocket message feed tab.
|
* log content render, Copy (clipboard), Download (assert
|
||||||
|
* ?download=true request fired), mobile tab selector.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
|
||||||
|
|
||||||
test.describe("Logs Page - Service Tabs @medium", () => {
|
function logsJsonBody(lines: string[]) {
|
||||||
test("logs page renders with named service tabs", async ({ frigateApp }) => {
|
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 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({
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
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.
|
* Navigation tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests sidebar (desktop) and bottombar (mobile) navigation,
|
* Covers sidebar (desktop) / bottombar (mobile) link set, conditional
|
||||||
* conditional nav items, settings menus, and their actual behaviors.
|
* 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 { test, expect } from "../fixtures/frigate-test";
|
||||||
import { BasePage } from "../pages/base.page";
|
import { BasePage } from "../pages/base.page";
|
||||||
|
|
||||||
test.describe("Navigation @critical", () => {
|
const PRIMARY_ROUTES = ["/review", "/explore", "/export"] as const;
|
||||||
test("app loads and renders page root", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logo is visible and links to home", async ({ frigateApp }) => {
|
test.describe("Navigation — primary links @critical", () => {
|
||||||
if (frigateApp.isMobile) {
|
test("every primary link is visible and navigates", async ({
|
||||||
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 ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const routes = ["/review", "/explore", "/export"];
|
for (const route of PRIMARY_ROUTES) {
|
||||||
for (const route of routes) {
|
|
||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
// Verify clicking each one actually navigates
|
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
for (const route of routes) {
|
for (const route of PRIMARY_ROUTES) {
|
||||||
await base.navigateTo(route);
|
await base.navigateTo(route);
|
||||||
await expect(frigateApp.page).toHaveURL(new RegExp(route));
|
await expect(frigateApp.page).toHaveURL(new RegExp(route));
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop sidebar is visible, mobile bottombar is visible", async ({
|
test("logo links home on desktop", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
test.skip(frigateApp.isMobile, "Sidebar logo is desktop-only");
|
||||||
}) => {
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.goto("/");
|
await frigateApp.page.locator("aside a[href='/']").first().click();
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
if (!frigateApp.isMobile) {
|
|
||||||
await expect(base.sidebar).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(base.sidebar).not.toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigate between all main pages without crash", async ({
|
test("unknown route redirects to /", async ({ frigateApp }) => {
|
||||||
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 }) => {
|
|
||||||
await frigateApp.page.goto("/nonexistent-route");
|
await frigateApp.page.goto("/nonexistent-route");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
const url = frigateApp.page.url();
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
const hasPageRoot = await frigateApp.page
|
await expect(
|
||||||
.locator("#pageRoot")
|
frigateApp.page.locator("[data-camera='front_door']"),
|
||||||
.isVisible()
|
).toBeVisible({ timeout: 10_000 });
|
||||||
.catch(() => false);
|
|
||||||
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Navigation - Conditional Items @critical", () => {
|
test.describe("Navigation — conditional items @critical", () => {
|
||||||
test("Faces nav hidden when face_recognition disabled", async ({
|
test("/faces is hidden when face_recognition.enabled is false", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
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 }) => {
|
test("/faces is visible when face_recognition.enabled is true (desktop)", async ({
|
||||||
if (frigateApp.isMobile) {
|
frigateApp,
|
||||||
test.skip();
|
}) => {
|
||||||
return;
|
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({
|
await frigateApp.installDefaults({
|
||||||
config: {
|
config: {
|
||||||
genai: {
|
genai: {
|
||||||
@@ -109,119 +84,83 @@ test.describe("Navigation - Conditional Items @critical", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/");
|
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,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
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;
|
|
||||||
}
|
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
config: { genai: { enabled: true, model: "llava" } },
|
config: { genai: { enabled: true, model: "llava" } },
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/");
|
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,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
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.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||||
test("settings gear opens menu with navigation items (desktop)", async ({
|
test.skip(
|
||||||
frigateApp,
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
}) => {
|
"Sidebar settings menu is desktop-only",
|
||||||
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("settings menu items navigate to correct routes (desktop)", async ({
|
const TARGETS = [
|
||||||
frigateApp,
|
{ label: "Settings", url: /\/settings/ },
|
||||||
}) => {
|
{ label: "System metrics", url: /\/system/ },
|
||||||
if (frigateApp.isMobile) {
|
{ label: "System logs", url: /\/logs/ },
|
||||||
test.skip();
|
{ label: "Configuration Editor", url: /\/config/ },
|
||||||
return;
|
];
|
||||||
}
|
|
||||||
const targets = [
|
for (const target of TARGETS) {
|
||||||
{ label: "Settings", url: "/settings" },
|
test(`menu → ${target.label} navigates`, async ({ frigateApp }) => {
|
||||||
{ label: "System metrics", url: "/system" },
|
|
||||||
{ label: "System logs", url: "/logs" },
|
|
||||||
{ label: "Configuration Editor", url: "/config" },
|
|
||||||
];
|
|
||||||
for (const target of targets) {
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const gearIcon = frigateApp.page
|
const gear = frigateApp.page
|
||||||
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
||||||
.first();
|
.first();
|
||||||
await gearIcon.click();
|
await gear.click();
|
||||||
await frigateApp.page.waitForTimeout(300);
|
await frigateApp.page.getByLabel(target.label).click();
|
||||||
const menuItem = frigateApp.page.getByLabel(target.label);
|
await expect(frigateApp.page).toHaveURL(target.url);
|
||||||
if (await menuItem.isVisible().catch(() => false)) {
|
});
|
||||||
await menuItem.click();
|
}
|
||||||
await expect(frigateApp.page).toHaveURL(
|
});
|
||||||
new RegExp(target.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
||||||
);
|
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 ({
|
test("mobile nav survives route change", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
|
const reviewLink = frigateApp.page.locator('a[href="/review"]').first();
|
||||||
const items = sidebarBottom.locator("div[class*='cursor-pointer']");
|
await reviewLink.click();
|
||||||
const count = await items.count();
|
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||||
if (count >= 2) {
|
await expect(
|
||||||
await items.nth(1).click();
|
frigateApp.page.locator('a[href="/review"]').first(),
|
||||||
await frigateApp.page.waitForTimeout(500);
|
).toBeVisible();
|
||||||
}
|
|
||||||
await expect(frigateApp.page.locator("body")).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 { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
activeSessionStatus,
|
||||||
|
noSessionStatus,
|
||||||
|
} from "../fixtures/mock-data/debug-replay";
|
||||||
|
|
||||||
test.describe("Replay Page @low", () => {
|
async function installStatusRoute(
|
||||||
test("replay page renders without crash", async ({ frigateApp }) => {
|
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.goto("/replay");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
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.goto("/replay");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
const buttons = frigateApp.page.locator("button");
|
frigateApp.page.getByRole("heading", {
|
||||||
const count = await buttons.count();
|
level: 2,
|
||||||
expect(count).toBeGreaterThan(0);
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+188
-158
@@ -1,200 +1,230 @@
|
|||||||
/**
|
/**
|
||||||
* Review/Events page tests -- CRITICAL tier.
|
* Review/Events page tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests severity tab switching by name (Alerts/Detections/Motion),
|
* Severity tabs, filter popovers, calendar, show-reviewed toggle,
|
||||||
* filter popover opening with camera names, show reviewed toggle,
|
* timeline, and the nested-overlay regression migrated from
|
||||||
* calendar button, and filter button interactions.
|
* radix-overlay-regressions.spec.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { BasePage } from "../pages/base.page";
|
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.describe("Review — severity tabs @critical", () => {
|
||||||
test("severity tabs render with Alerts, Detections, Motion", async ({
|
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,
|
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 frigateApp.goto("/review");
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||||
timeout: 10_000,
|
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||||
await expect(frigateApp.page.getByLabel("Detections")).toBeVisible();
|
await review.detectionsTab.click();
|
||||||
// Motion uses role="radio" to distinguish from other Motion elements
|
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||||
await expect(
|
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
|
||||||
frigateApp.page.getByRole("radio", { name: "Motion" }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Alerts tab is active by default", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const alertsTab = frigateApp.page.getByLabel("Alerts");
|
|
||||||
await expect(alertsTab).toHaveAttribute("data-state", "on");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking Detections tab makes it active and deactivates Alerts", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const alertsTab = frigateApp.page.getByLabel("Alerts");
|
|
||||||
const detectionsTab = frigateApp.page.getByLabel("Detections");
|
|
||||||
|
|
||||||
await detectionsTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
|
|
||||||
await expect(detectionsTab).toHaveAttribute("data-state", "on");
|
|
||||||
await expect(alertsTab).toHaveAttribute("data-state", "off");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking Motion tab makes it active", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" });
|
|
||||||
await motionTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
await expect(motionTab).toHaveAttribute("data-state", "on");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching back to Alerts from Detections works", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
await frigateApp.page.getByLabel("Detections").click();
|
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
await frigateApp.page.getByLabel("Alerts").click();
|
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
|
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Review Page - Filters @critical", () => {
|
test.describe("Review — filters (desktop) @critical", () => {
|
||||||
test("All Cameras filter button opens popover with camera names", async ({
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Filter bar differs on mobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Cameras popover lists configured camera names", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
const review = new ReviewPage(frigateApp.page, true);
|
||||||
|
await expect(review.camerasFilterTrigger).toBeVisible({ timeout: 5_000 });
|
||||||
const camerasBtn = frigateApp.page.getByRole("button", {
|
await review.camerasFilterTrigger.click();
|
||||||
name: /cameras/i,
|
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
|
||||||
});
|
|
||||||
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
|
|
||||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing the Cameras popover with Escape leaves body interactive", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Migrated from radix-overlay-regressions.spec.ts.
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const review = new ReviewPage(frigateApp.page, true);
|
||||||
|
await review.camerasFilterTrigger.click();
|
||||||
|
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
await expect(review.filterOverlay).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
await waitForBodyInteractive(frigateApp.page);
|
||||||
|
await expectBodyInteractive(frigateApp.page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Labels are shown inside the General Filter dialog", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// Labels are surfaced inside the "Filter" button's GeneralFilterContent
|
||||||
|
// dialog, not as a standalone top-level button. We open that dialog and
|
||||||
|
// confirm labels from the camera config are listed there.
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
const filterBtn = frigateApp.page
|
||||||
|
.getByRole("button", { name: /^filter$/i })
|
||||||
|
.first();
|
||||||
|
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
|
||||||
|
await filterBtn.click();
|
||||||
|
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
// The default mock config for front_door tracks "person"
|
||||||
|
await expect(overlay.first().getByText(/person/i)).toBeVisible();
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Show Reviewed toggle is clickable", async ({ frigateApp }) => {
|
test("Zones popover lists configured zones inside the General Filter dialog", async ({
|
||||||
await frigateApp.goto("/review");
|
frigateApp,
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
}) => {
|
||||||
|
// Override config to guarantee a known zone on front_door.
|
||||||
const showReviewed = frigateApp.page.getByRole("button", {
|
await frigateApp.installDefaults({
|
||||||
name: /reviewed/i,
|
config: {
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
zones: {
|
||||||
|
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (await showReviewed.isVisible().catch(() => false)) {
|
await frigateApp.goto("/review");
|
||||||
await showReviewed.click();
|
const filterBtn = frigateApp.page
|
||||||
await frigateApp.page.waitForTimeout(500);
|
.getByRole("button", { name: /^filter$/i })
|
||||||
// Toggle should change state
|
.first();
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
|
||||||
}
|
await filterBtn.click();
|
||||||
|
|
||||||
|
const overlay = frigateApp.page.locator(
|
||||||
|
"[data-radix-popper-content-wrapper], [role='dialog']",
|
||||||
|
);
|
||||||
|
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(overlay.first().getByText(/front.?yard/i)).toBeVisible();
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Last 24 Hours calendar button opens date picker", async ({
|
test("Calendar trigger opens a date picker popover", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
const review = new ReviewPage(frigateApp.page, true);
|
||||||
|
await expect(review.calendarTrigger).toBeVisible({ timeout: 5_000 });
|
||||||
|
await review.calendarTrigger.click();
|
||||||
|
|
||||||
const calendarBtn = frigateApp.page.getByRole("button", {
|
// react-day-picker v9 renders a role="grid" calendar with day cells
|
||||||
name: /24 hours|calendar|date/i,
|
// as buttons inside gridcells (e.g. "Wednesday, April 1st, 2026").
|
||||||
});
|
// The calendar is placed directly in the DOM (not always inside a
|
||||||
if (await calendarBtn.isVisible().catch(() => false)) {
|
// Radix popper wrapper), so scope by the grid role instead.
|
||||||
await calendarBtn.click();
|
const calendarGrid = frigateApp.page.locator('[role="grid"]').first();
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await expect(calendarGrid).toBeVisible({ timeout: 3_000 });
|
||||||
// Popover should open
|
const dayButton = calendarGrid.locator('[role="gridcell"] button').first();
|
||||||
const popover = frigateApp.page.locator(
|
await expect(dayButton).toBeVisible({ timeout: 3_000 });
|
||||||
"[data-radix-popper-content-wrapper]",
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
);
|
|
||||||
if (
|
|
||||||
await popover
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false)
|
|
||||||
) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Filter button opens filter popover", async ({ frigateApp }) => {
|
test("Show Reviewed switch flips its checked state", async ({
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
const filterBtn = frigateApp.page.getByRole("button", {
|
|
||||||
name: /^filter$/i,
|
|
||||||
});
|
|
||||||
if (await filterBtn.isVisible().catch(() => false)) {
|
|
||||||
await filterBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// Popover or dialog should open
|
|
||||||
const popover = frigateApp.page.locator(
|
|
||||||
"[data-radix-popper-content-wrapper], [role='dialog']",
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
await popover
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false)
|
|
||||||
) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Review Page - Timeline @critical", () => {
|
|
||||||
test("review page has timeline with time markers (desktop)", async ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
// "Show Reviewed" is a Radix Switch (role=switch), not a button.
|
||||||
test.skip();
|
// It filters review data client-side; it does not trigger a new
|
||||||
return;
|
// /api/review network request. Verify the switch state toggles.
|
||||||
}
|
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const showReviewedSwitch = frigateApp.page.getByRole("switch", {
|
||||||
// Timeline renders time labels like "4:30 PM"
|
name: /show reviewed/i,
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
});
|
||||||
expect(pageText).toMatch(/[AP]M/);
|
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.describe("Review — timeline (desktop) @critical", () => {
|
||||||
test("navigate to review from live page works", async ({ frigateApp }) => {
|
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("/");
|
await frigateApp.goto("/");
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
const base = new BasePage(frigateApp.page, false);
|
||||||
await base.navigateTo("/review");
|
await base.navigateTo("/review");
|
||||||
await expect(frigateApp.page).toHaveURL(/\/review/);
|
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||||
// Severity tabs should be visible
|
await base.navigateTo("/");
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+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.
|
* Tab switching, version + last-refreshed display, and the
|
||||||
* Navigates to /system#general explicitly so useHashState resolves
|
* RestartDialog cancel flow.
|
||||||
* the tab state deterministically.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
expectBodyInteractive,
|
||||||
|
waitForBodyInteractive,
|
||||||
|
} from "../helpers/overlay-interaction";
|
||||||
|
|
||||||
test.describe("System Page @medium", () => {
|
test.describe("System — tabs @medium", () => {
|
||||||
test("system page renders with tab buttons", async ({ frigateApp }) => {
|
test("general tab is active by default via #general hash", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@@ -20,7 +26,7 @@ test.describe("System Page @medium", () => {
|
|||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
@@ -29,18 +35,6 @@ test.describe("System Page @medium", () => {
|
|||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ 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 frigateApp.page.getByLabel("Select storage").click();
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@@ -53,29 +47,22 @@ test.describe("System Page @medium", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking Cameras tab activates it and deactivates General", async ({
|
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
await frigateApp.page.getByLabel("Select cameras").click();
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 5_000 },
|
{ 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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
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("0.15.0-test")).toBeVisible();
|
||||||
await expect(frigateApp.page.getByText(/Last refreshed/)).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