basic e2e frontend test framework

This commit is contained in:
Josh Hawkins 2026-04-06 11:15:22 -05:00
parent ed3bebc967
commit 6b24219e48
28 changed files with 1824 additions and 0 deletions

View File

@ -0,0 +1,80 @@
/* eslint-disable react-hooks/rules-of-hooks */
/**
* Extended Playwright test fixture with FrigateApp.
*
* Every test imports `test` and `expect` from this file instead of
* @playwright/test directly. The `frigateApp` fixture provides a
* fully mocked Frigate frontend ready for interaction.
*
* CRITICAL: All route/WS handlers are registered before page.goto()
* to prevent AuthProvider from redirecting to login.html.
*/
import { test as base, expect, type Page } from "@playwright/test";
import {
ApiMocker,
MediaMocker,
type ApiMockOverrides,
} from "../helpers/api-mocker";
import { WsMocker } from "../helpers/ws-mocker";
export class FrigateApp {
public api: ApiMocker;
public media: MediaMocker;
public ws: WsMocker;
public page: Page;
private isDesktop: boolean;
constructor(page: Page, projectName: string) {
this.page = page;
this.api = new ApiMocker(page);
this.media = new MediaMocker(page);
this.ws = new WsMocker();
this.isDesktop = projectName === "desktop";
}
get isMobile() {
return !this.isDesktop;
}
/** Install all mocks with default data. Call before goto(). */
async installDefaults(overrides?: ApiMockOverrides) {
// Mock i18n locale files to prevent 404s
await this.page.route("**/locales/**", async (route) => {
// Let the request through to the built files
return route.fallback();
});
await this.ws.install(this.page);
await this.media.install();
await this.api.install(overrides);
}
/** Navigate to a page. Always call installDefaults() first. */
async goto(path: string) {
await this.page.goto(path);
// Wait for the app to render past the loading indicator
await this.page.waitForSelector("#pageRoot", { timeout: 10_000 });
}
/** Navigate to a page that may show a loading indicator */
async gotoAndWait(path: string, selector: string) {
await this.page.goto(path);
await this.page.waitForSelector(selector, { timeout: 10_000 });
}
}
type FrigateFixtures = {
frigateApp: FrigateApp;
};
export const test = base.extend<FrigateFixtures>({
frigateApp: async ({ page }, use, testInfo) => {
const app = new FrigateApp(page, testInfo.project.name);
await app.installDefaults();
await use(app);
},
});
export { expect };

View File

@ -0,0 +1,77 @@
/**
* Camera activity WebSocket payload factory.
*
* The camera_activity topic payload is double-serialized:
* the WS message contains { topic: "camera_activity", payload: JSON.stringify(activityMap) }
*/
export interface CameraActivityState {
config: {
enabled: boolean;
detect: boolean;
record: boolean;
snapshots: boolean;
audio: boolean;
audio_transcription: boolean;
notifications: boolean;
notifications_suspended: number;
autotracking: boolean;
alerts: boolean;
detections: boolean;
object_descriptions: boolean;
review_descriptions: boolean;
};
motion: boolean;
objects: Array<{
label: string;
score: number;
box: [number, number, number, number];
area: number;
ratio: number;
region: [number, number, number, number];
current_zones: string[];
id: string;
}>;
audio_detections: Array<{
label: string;
score: number;
}>;
}
function defaultCameraActivity(): CameraActivityState {
return {
config: {
enabled: true,
detect: true,
record: true,
snapshots: true,
audio: false,
audio_transcription: false,
notifications: false,
notifications_suspended: 0,
autotracking: false,
alerts: true,
detections: true,
object_descriptions: false,
review_descriptions: false,
},
motion: false,
objects: [],
audio_detections: [],
};
}
export function cameraActivityPayload(
cameras: string[],
overrides?: Partial<Record<string, Partial<CameraActivityState>>>,
): string {
const activity: Record<string, CameraActivityState> = {};
for (const name of cameras) {
activity[name] = {
...defaultCameraActivity(),
...overrides?.[name],
} as CameraActivityState;
}
// Double-serialize: the WS payload is a JSON string
return JSON.stringify(activity);
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,76 @@
/**
* FrigateConfig factory for E2E tests.
*
* Uses a real config snapshot generated from the Python backend's FrigateConfig
* model. This guarantees all fields are present and match what the app expects.
* Tests override specific fields via DeepPartial.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const configSnapshot = JSON.parse(
readFileSync(resolve(__dirname, "config-snapshot.json"), "utf-8"),
);
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
function deepMerge<T extends Record<string, unknown>>(
base: T,
overrides?: DeepPartial<T>,
): T {
if (!overrides) return base;
const result = { ...base };
for (const key of Object.keys(overrides) as (keyof T)[]) {
const val = overrides[key];
if (
val !== undefined &&
typeof val === "object" &&
val !== null &&
!Array.isArray(val) &&
typeof base[key] === "object" &&
base[key] !== null &&
!Array.isArray(base[key])
) {
result[key] = deepMerge(
base[key] as Record<string, unknown>,
val as DeepPartial<Record<string, unknown>>,
) as T[keyof T];
} else if (val !== undefined) {
result[key] = val as T[keyof T];
}
}
return result;
}
// The base config is a real snapshot from the Python backend.
// Apply test-specific overrides: friendly names, camera groups, version.
export const BASE_CONFIG = {
...configSnapshot,
version: "0.15.0-test",
cameras: {
...configSnapshot.cameras,
front_door: {
...configSnapshot.cameras.front_door,
friendly_name: "Front Door",
},
backyard: {
...configSnapshot.cameras.backyard,
friendly_name: "Backyard",
},
garage: {
...configSnapshot.cameras.garage,
friendly_name: "Garage",
},
},
};
export function configFactory(
overrides?: DeepPartial<typeof BASE_CONFIG>,
): typeof BASE_CONFIG {
return deepMerge(BASE_CONFIG, overrides);
}

View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Generate a complete FrigateConfig snapshot for E2E tests.
Run from the repo root:
python3 web/e2e/fixtures/mock-data/generate-config-snapshot.py
This generates config-snapshot.json with all fields from the Python backend,
plus runtime-computed fields that the API adds but aren't in the Pydantic model.
"""
import json
import sys
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
from frigate.config import FrigateConfig # noqa: E402
# Minimal config with 3 test cameras and camera groups
MINIMAL_CONFIG = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 720, "width": 1280, "fps": 5},
},
"backyard": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 720, "width": 1280, "fps": 5},
},
"garage": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 720, "width": 1280, "fps": 5},
},
},
"camera_groups": {
"default": {
"cameras": ["front_door", "backyard", "garage"],
"icon": "generic",
"order": 0,
},
"outdoor": {
"cameras": ["front_door", "backyard"],
"icon": "generic",
"order": 1,
},
},
}
def generate():
config = FrigateConfig.model_validate_json(json.dumps(MINIMAL_CONFIG))
with warnings.catch_warnings():
warnings.simplefilter("ignore")
snapshot = config.model_dump()
# Add runtime-computed fields that the API serves but aren't in the
# Pydantic model dump. These are computed by the backend when handling
# GET /api/config requests.
# model.all_attributes: flattened list of all attribute labels from attributes_map
all_attrs = set()
for attrs in snapshot.get("model", {}).get("attributes_map", {}).values():
all_attrs.update(attrs)
snapshot["model"]["all_attributes"] = sorted(all_attrs)
# model.colormap: empty by default (populated at runtime from model output)
snapshot["model"]["colormap"] = {}
# Convert to JSON-serializable format (handles datetime, Path, etc.)
output = json.dumps(snapshot, default=str)
# Write to config-snapshot.json in the same directory as this script
output_path = Path(__file__).parent / "config-snapshot.json"
output_path.write_text(output)
print(f"Generated {output_path} ({len(output)} bytes)")
if __name__ == "__main__":
generate()

View File

@ -0,0 +1,39 @@
/**
* User profile factories for E2E tests.
*/
export interface UserProfile {
username: string;
role: string;
allowed_cameras: string[] | null;
}
export function adminProfile(overrides?: Partial<UserProfile>): UserProfile {
return {
username: "admin",
role: "admin",
allowed_cameras: null,
...overrides,
};
}
export function viewerProfile(overrides?: Partial<UserProfile>): UserProfile {
return {
username: "viewer",
role: "viewer",
allowed_cameras: null,
...overrides,
};
}
export function restrictedProfile(
cameras: string[],
overrides?: Partial<UserProfile>,
): UserProfile {
return {
username: "restricted",
role: "viewer",
allowed_cameras: cameras,
...overrides,
};
}

View File

@ -0,0 +1,76 @@
/**
* FrigateStats factory for E2E tests.
*/
import type { DeepPartial } from "./config";
function cameraStats(_name: string) {
return {
audio_dBFPS: 0,
audio_rms: 0,
camera_fps: 5.0,
capture_pid: 100,
detection_enabled: 1,
detection_fps: 5.0,
ffmpeg_pid: 101,
pid: 102,
process_fps: 5.0,
skipped_fps: 0,
connection_quality: "excellent" as const,
expected_fps: 5,
reconnects_last_hour: 0,
stalls_last_hour: 0,
};
}
export const BASE_STATS = {
cameras: {
front_door: cameraStats("front_door"),
backyard: cameraStats("backyard"),
garage: cameraStats("garage"),
},
cpu_usages: {
"1": { cmdline: "frigate.app", cpu: "5.0", cpu_average: "4.5", mem: "2.1" },
},
detectors: {
cpu: {
detection_start: 0,
inference_speed: 75.5,
pid: 200,
},
},
gpu_usages: {},
npu_usages: {},
processes: {},
service: {
last_updated: Date.now() / 1000,
storage: {
"/media/frigate/recordings": {
free: 50000000000,
total: 100000000000,
used: 50000000000,
mount_type: "ext4",
},
"/tmp/cache": {
free: 500000000,
total: 1000000000,
used: 500000000,
mount_type: "tmpfs",
},
},
uptime: 86400,
latest_version: "0.15.0",
version: "0.15.0-test",
},
camera_fps: 15.0,
process_fps: 15.0,
skipped_fps: 0,
detection_fps: 15.0,
};
export function statsFactory(
overrides?: DeepPartial<typeof BASE_STATS>,
): typeof BASE_STATS {
if (!overrides) return BASE_STATS;
return { ...BASE_STATS, ...overrides } as typeof BASE_STATS;
}

7
web/e2e/global-setup.ts Normal file
View File

@ -0,0 +1,7 @@
import { execSync } from "child_process";
import path from "path";
export default function globalSetup() {
const webDir = path.resolve(__dirname, "..");
execSync("npm run e2e:build", { cwd: webDir, stdio: "inherit" });
}

View File

@ -0,0 +1,225 @@
/**
* REST API mock using Playwright's page.route().
*
* Intercepts all /api/* requests and returns factory-generated responses.
* Must be installed BEFORE page.goto() to prevent auth redirects.
*/
import type { Page } from "@playwright/test";
import {
BASE_CONFIG,
type DeepPartial,
configFactory,
} from "../fixtures/mock-data/config";
import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile";
import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats";
// 1x1 transparent PNG
const PLACEHOLDER_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
export interface ApiMockOverrides {
config?: DeepPartial<typeof BASE_CONFIG>;
profile?: UserProfile;
stats?: DeepPartial<typeof BASE_STATS>;
reviews?: unknown[];
events?: unknown[];
exports?: unknown[];
cases?: unknown[];
faces?: Record<string, unknown>;
configRaw?: string;
configSchema?: Record<string, unknown>;
}
export class ApiMocker {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async install(overrides?: ApiMockOverrides) {
const config = configFactory(overrides?.config);
const profile = overrides?.profile ?? adminProfile();
const stats = statsFactory(overrides?.stats);
// Config endpoint
await this.page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
// Profile endpoint (AuthProvider fetches /profile directly via axios,
// which resolves to /api/profile due to axios.defaults.baseURL)
await this.page.route("**/profile", (route) =>
route.fulfill({ json: profile }),
);
// Stats endpoint
await this.page.route("**/api/stats", (route) =>
route.fulfill({ json: stats }),
);
// Reviews
await this.page.route("**/api/reviews**", (route) =>
route.fulfill({ json: overrides?.reviews ?? [] }),
);
// Events / search
await this.page.route("**/api/events**", (route) =>
route.fulfill({ json: overrides?.events ?? [] }),
);
// Exports
await this.page.route("**/api/export**", (route) =>
route.fulfill({ json: overrides?.exports ?? [] }),
);
// Cases
await this.page.route("**/api/cases", (route) =>
route.fulfill({ json: overrides?.cases ?? [] }),
);
// Faces
await this.page.route("**/api/faces", (route) =>
route.fulfill({ json: overrides?.faces ?? {} }),
);
// Logs
await this.page.route("**/api/logs/**", (route) =>
route.fulfill({
contentType: "text/plain",
body: "[2026-04-06 10:00:00] INFO: Frigate started\n[2026-04-06 10:00:01] INFO: Cameras loaded\n",
}),
);
// Config raw
await this.page.route("**/api/config/raw", (route) =>
route.fulfill({
contentType: "text/plain",
body:
overrides?.configRaw ??
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n",
}),
);
// Config schema
await this.page.route("**/api/config/schema.json", (route) =>
route.fulfill({
json: overrides?.configSchema ?? { type: "object", properties: {} },
}),
);
// Config set (mutation)
await this.page.route("**/api/config/set", (route) =>
route.fulfill({ json: { success: true, require_restart: false } }),
);
// Go2RTC streams
await this.page.route("**/api/go2rtc/streams**", (route) =>
route.fulfill({ json: {} }),
);
// Profiles
await this.page.route("**/api/profiles**", (route) =>
route.fulfill({
json: { profiles: [], active_profile: null, last_activated: {} },
}),
);
// Motion search
await this.page.route("**/api/motion_search**", (route) =>
route.fulfill({ json: { job_id: "test-job" } }),
);
// Region grid
await this.page.route("**/api/*/region_grid", (route) =>
route.fulfill({ json: {} }),
);
// Debug replay
await this.page.route("**/api/debug_replay/**", (route) =>
route.fulfill({ json: {} }),
);
// Generic mutation catch-all for remaining endpoints.
// Uses route.fallback() to defer to more specific routes registered above.
// Playwright matches routes in reverse registration order (last wins),
// so this catch-all must use fallback() to let specific routes take precedence.
await this.page.route("**/api/**", (route) => {
const method = route.request().method();
if (
method === "POST" ||
method === "PUT" ||
method === "PATCH" ||
method === "DELETE"
) {
return route.fulfill({ json: { success: true } });
}
// Fall through to more specific routes for GET requests
return route.fallback();
});
}
}
export class MediaMocker {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async install() {
// Camera snapshots
await this.page.route("**/api/*/latest.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Clips and thumbnails
await this.page.route("**/clips/**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Event thumbnails
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Event snapshots
await this.page.route("**/api/events/*/snapshot.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// VOD / recordings
await this.page.route("**/vod/**", (route) =>
route.fulfill({
contentType: "application/vnd.apple.mpegurl",
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
}),
);
// Live streams
await this.page.route("**/live/**", (route) =>
route.fulfill({
contentType: "application/vnd.apple.mpegurl",
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
}),
);
}
}

View File

@ -0,0 +1,125 @@
/**
* WebSocket mock using Playwright's native page.routeWebSocket().
*
* Intercepts the app's WebSocket connection and simulates the Frigate
* WS protocol: onConnect handshake, camera_activity expansion, and
* topic-based state updates.
*/
import type { Page, WebSocketRoute } from "@playwright/test";
import { cameraActivityPayload } from "../fixtures/mock-data/camera-activity";
export class WsMocker {
private mockWs: WebSocketRoute | null = null;
private cameras: string[];
constructor(cameras: string[] = ["front_door", "backyard", "garage"]) {
this.cameras = cameras;
}
async install(page: Page) {
await page.routeWebSocket("**/ws", (ws) => {
this.mockWs = ws;
ws.onMessage((msg) => {
this.handleClientMessage(msg.toString());
});
});
}
private handleClientMessage(raw: string) {
let data: { topic: string; payload?: unknown; message?: string };
try {
data = JSON.parse(raw);
} catch {
return;
}
if (data.topic === "onConnect") {
// Send initial camera_activity state
this.sendCameraActivity();
// Send initial stats
this.send(
"stats",
JSON.stringify({
cameras: Object.fromEntries(
this.cameras.map((c) => [
c,
{
camera_fps: 5,
detection_fps: 5,
process_fps: 5,
skipped_fps: 0,
detection_enabled: 1,
connection_quality: "excellent",
},
]),
),
service: {
last_updated: Date.now() / 1000,
uptime: 86400,
version: "0.15.0-test",
latest_version: "0.15.0",
storage: {},
},
detectors: {},
cpu_usages: {},
gpu_usages: {},
camera_fps: 15,
process_fps: 15,
skipped_fps: 0,
detection_fps: 15,
}),
);
}
// Echo back state commands (e.g., modelState, jobState, etc.)
if (data.topic === "modelState") {
this.send("model_state", JSON.stringify({}));
}
if (data.topic === "embeddingsReindexProgress") {
this.send("embeddings_reindex_progress", JSON.stringify(null));
}
if (data.topic === "birdseyeLayout") {
this.send("birdseye_layout", JSON.stringify(null));
}
if (data.topic === "jobState") {
this.send("job_state", JSON.stringify({}));
}
if (data.topic === "audioTranscriptionState") {
this.send("audio_transcription_state", JSON.stringify("idle"));
}
// Camera toggle commands: echo back the new state
const toggleMatch = data.topic?.match(
/^(.+)\/(detect|recordings|snapshots|audio|enabled|notifications|ptz_autotracker|review_alerts|review_detections|object_descriptions|review_descriptions|audio_transcription)\/set$/,
);
if (toggleMatch) {
const [, camera, feature] = toggleMatch;
this.send(`${camera}/${feature}/state`, data.payload);
}
}
/** Send a raw WS message to the app */
send(topic: string, payload: unknown) {
if (!this.mockWs) return;
this.mockWs.send(JSON.stringify({ topic, payload }));
}
/** Send camera_activity with default or custom state */
sendCameraActivity(overrides?: Parameters<typeof cameraActivityPayload>[1]) {
const payload = cameraActivityPayload(this.cameras, overrides);
this.send("camera_activity", payload);
}
/** Send a review update */
sendReview(review: unknown) {
this.send("reviews", JSON.stringify(review));
}
/** Send an event update */
sendEvent(event: unknown) {
this.send("events", JSON.stringify(event));
}
}

View File

@ -0,0 +1,82 @@
/**
* Base page object with viewport-aware navigation helpers.
*
* Desktop: clicks sidebar NavLink elements.
* Mobile: clicks bottombar NavLink elements.
*/
import type { Page, Locator } from "@playwright/test";
export class BasePage {
constructor(
protected page: Page,
public isDesktop: boolean,
) {}
get isMobile() {
return !this.isDesktop;
}
/** The sidebar (desktop only) */
get sidebar(): Locator {
return this.page.locator("aside");
}
/** The bottombar (mobile only) */
get bottombar(): Locator {
return this.page
.locator('[data-bottombar="true"]')
.or(this.page.locator(".absolute.inset-x-4.bottom-0").first());
}
/** The main page content area */
get pageRoot(): Locator {
return this.page.locator("#pageRoot");
}
/** Navigate using a NavLink by its href */
async navigateTo(path: string) {
// Wait for any in-progress React renders to settle before clicking
await this.page.waitForLoadState("domcontentloaded");
// Use page.click with a CSS selector to avoid stale element issues
// when React re-renders the nav during route transitions.
// force: true bypasses actionability checks that fail when React
// detaches and reattaches nav elements during re-renders.
const selector = this.isDesktop
? `aside a[href="${path}"]`
: `a[href="${path}"]`;
// Use dispatchEvent to bypass actionability checks that fail when
// React tooltip wrappers detach/reattach nav elements during re-renders
await this.page.locator(selector).first().dispatchEvent("click");
// React Router navigates client-side, wait for URL update
if (path !== "/") {
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await this.page.waitForURL(new RegExp(escaped), { timeout: 10_000 });
}
}
/** Navigate to Live page */
async goToLive() {
await this.navigateTo("/");
}
/** Navigate to Review page */
async goToReview() {
await this.navigateTo("/review");
}
/** Navigate to Explore page */
async goToExplore() {
await this.navigateTo("/explore");
}
/** Navigate to Export page */
async goToExport() {
await this.navigateTo("/export");
}
/** Check if the page has loaded */
async waitForPageLoad() {
await this.page.waitForSelector("#pageRoot", { timeout: 10_000 });
}
}

View File

@ -0,0 +1,56 @@
import { defineConfig, devices } from "@playwright/test";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const webRoot = resolve(__dirname, "..");
const DESKTOP_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const MOBILE_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
export default defineConfig({
testDir: "./specs",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI ? [["json"], ["html"]] : [["html"]],
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: "http://localhost:4173",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
webServer: {
command: "npx vite preview --port 4173",
port: 4173,
cwd: webRoot,
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: "desktop",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 },
userAgent: DESKTOP_UA,
},
},
{
name: "mobile",
use: {
...devices["Desktop Chrome"],
viewport: { width: 390, height: 844 },
userAgent: MOBILE_UA,
isMobile: true,
hasTouch: true,
},
},
],
});

View File

@ -0,0 +1,70 @@
/**
* Auth and cross-cutting tests -- HIGH tier.
*
* Tests protected routes, unauthorized redirect,
* and app-wide behaviors.
*/
import { test, expect } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth & Protected Routes @high", () => {
test("admin can access /system", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("admin can access /config", async ({ frigateApp }) => {
await frigateApp.goto("/config");
// Config editor may take time to load Monaco
await frigateApp.page.waitForTimeout(3000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("admin can access /logs", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("viewer is redirected from admin routes", async ({
frigateApp,
page,
}) => {
// Re-install mocks with viewer profile
await frigateApp.installDefaults({
profile: viewerProfile(),
});
await page.goto("/system");
await page.waitForTimeout(2000);
// Should be redirected to unauthorized page
const url = page.url();
const hasAccessDenied = url.includes("unauthorized");
const bodyText = await page.textContent("body");
const showsAccessDenied =
bodyText?.includes("Access Denied") ||
bodyText?.includes("permission") ||
hasAccessDenied;
expect(showsAccessDenied).toBeTruthy();
});
test("all main pages render without crash", async ({ frigateApp }) => {
// Smoke test all user-accessible routes
const routes = ["/", "/review", "/explore", "/export", "/settings"];
for (const route of routes) {
await frigateApp.goto(route);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
}
});
test("all admin pages render without crash", async ({ frigateApp }) => {
const routes = ["/system", "/logs"];
for (const route of routes) {
await frigateApp.goto(route);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
}
});
});

View File

@ -0,0 +1,22 @@
/**
* Chat page tests -- MEDIUM tier.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Chat Page @medium", () => {
test("chat page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("chat page has interactive elements", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
// Should have interactive elements (input, textarea, or buttons)
const interactive = frigateApp.page.locator("input, textarea, button");
const count = await interactive.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,18 @@
/**
* Classification page tests -- MEDIUM tier.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Classification @medium", () => {
test("classification page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/classification");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("classification page shows content", async ({ frigateApp }) => {
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});

View File

@ -0,0 +1,23 @@
/**
* Config Editor page tests -- MEDIUM tier.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Config Editor @medium", () => {
test("config editor page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/config");
// Monaco editor may take time to load
await frigateApp.page.waitForTimeout(3000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("config editor has save button", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(3000);
// Should have at least a save or action button
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,82 @@
/**
* Explore page tests -- HIGH tier.
*
* Tests search input, filter dialogs, camera filter, calendar filter,
* and search result interactions.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Explore Page @high", () => {
test("explore page renders with search and filter controls", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
const pageRoot = frigateApp.page.locator("#pageRoot");
await expect(pageRoot).toBeVisible();
// Should have filter buttons (camera filter, calendar, etc.)
const buttons = frigateApp.page.locator("#pageRoot button");
await expect(buttons.first()).toBeVisible({ timeout: 10_000 });
});
test("camera filter button opens camera selector", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
// Find and click the camera filter button (has camera/video icon)
const filterButtons = frigateApp.page.locator("#pageRoot button");
// Click the first filter button
await filterButtons.first().click();
await frigateApp.page.waitForTimeout(500);
// A popover, dropdown, or dialog should appear
const overlay = frigateApp.page.locator(
'[role="dialog"], [role="menu"], [data-radix-popper-content-wrapper], [data-radix-menu-content]',
);
const overlayVisible = await overlay
.first()
.isVisible()
.catch(() => false);
// The button click should not crash the page
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// If an overlay appeared, it should be dismissible
if (overlayVisible) {
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
}
});
test("search input accepts text", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
// Find the search input (InputWithTags component)
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await searchInput.fill("person");
await expect(searchInput).toHaveValue("person");
}
});
test("filter button click opens overlay and escape closes it", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
// Click the first filter button in the page
const firstButton = frigateApp.page.locator("#pageRoot button").first();
await expect(firstButton).toBeVisible({ timeout: 5_000 });
await firstButton.click();
await frigateApp.page.waitForTimeout(500);
// An overlay may have appeared -- dismiss it
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
// Page should still be functional
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("explore page shows summary or empty state", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(2000);
// With no search results, should show either summary view or empty state
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,31 @@
/**
* Export page tests -- HIGH tier.
*
* Tests export list, export cards, download/rename/delete actions,
* and the export dialog.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Export Page @high", () => {
test("export page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("empty state shows when no exports", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// With empty exports mock, should show empty state
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("export page has filter controls", async ({ frigateApp }) => {
await frigateApp.goto("/export");
// Should render buttons/controls
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});

View File

@ -0,0 +1,19 @@
/**
* Face Library page tests -- MEDIUM tier.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Face Library @medium", () => {
test("face library page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/faces");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("face library shows empty state or content", async ({ frigateApp }) => {
await frigateApp.goto("/faces");
await frigateApp.page.waitForTimeout(2000);
// With empty faces mock, should show empty state
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});

194
web/e2e/specs/live.spec.ts Normal file
View File

@ -0,0 +1,194 @@
/**
* Live page tests -- CRITICAL tier.
*
* Tests camera dashboard, single camera view, camera groups,
* feature toggles, and context menus on both desktop and mobile.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Live Dashboard @critical", () => {
test("dashboard renders with camera grid", async ({ frigateApp }) => {
await frigateApp.goto("/");
// Should see camera containers for each mock camera
const pageRoot = frigateApp.page.locator("#pageRoot");
await expect(pageRoot).toBeVisible();
// Check that camera names from config are referenced in the page
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
await expect(
frigateApp.page.locator("[data-camera='backyard']"),
).toBeVisible({ timeout: 10_000 });
await expect(frigateApp.page.locator("[data-camera='garage']")).toBeVisible(
{ timeout: 10_000 },
);
});
test("click camera enters single camera view", async ({ frigateApp }) => {
await frigateApp.goto("/");
// Click the front_door camera card
const cameraCard = frigateApp.page
.locator("[data-camera='front_door']")
.first();
await cameraCard.click({ timeout: 10_000 });
// URL hash should change to include the camera name
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
test("back button returns to dashboard from single camera", async ({
frigateApp,
}) => {
// Navigate directly to single camera view via hash
await frigateApp.goto("/#front_door");
// Wait for single camera view to render
await frigateApp.page.waitForTimeout(1000);
// Click back button
const backButton = frigateApp.page
.locator("button")
.filter({
has: frigateApp.page.locator("svg"),
})
.first();
await backButton.click();
// Should return to dashboard (hash cleared)
await frigateApp.page.waitForTimeout(1000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("fullscreen toggle works", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// The fullscreen button should be present (fixed position at bottom-right)
const fullscreenBtn = frigateApp.page.locator("button:has(svg)").last();
await expect(fullscreenBtn).toBeVisible({ timeout: 10_000 });
});
test("camera group selector is visible on live page", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
// On mobile, the camera group selector is in the header
await frigateApp.goto("/");
// Just verify the page renders without crash
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
return;
}
await frigateApp.goto("/");
// On desktop, camera group selector is in the sidebar below the Live nav item
await expect(frigateApp.page.locator("aside")).toBeVisible();
});
test("page renders without crash when no cameras match group", async ({
frigateApp,
}) => {
// Navigate to a non-existent camera group
await frigateApp.page.goto("/?group=nonexistent");
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("birdseye view accessible when enabled", async ({ frigateApp }) => {
// Birdseye is enabled in our default config
await frigateApp.goto("/#birdseye");
await frigateApp.page.waitForTimeout(2000);
// Should not crash - either shows birdseye or falls back
const body = frigateApp.page.locator("body");
await expect(body).toBeVisible();
});
});
test.describe("Live Camera Features @critical", () => {
test("single camera view renders with controls", async ({ frigateApp }) => {
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// The page should render without crash
await expect(frigateApp.page.locator("body")).toBeVisible();
// Should have some buttons (back, fullscreen, settings, etc.)
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
test("camera feature toggles are clickable", async ({ frigateApp }) => {
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Find toggle/switch elements - FilterSwitch components
const switches = frigateApp.page.locator('button[role="switch"]');
const count = await switches.count();
if (count > 0) {
// Click the first switch to toggle it
await switches.first().click();
// Should not crash
await expect(frigateApp.page.locator("body")).toBeVisible();
}
});
test("keyboard shortcut f does not crash", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// Press 'f' for fullscreen
await frigateApp.page.keyboard.press("f");
await frigateApp.page.waitForTimeout(500);
// Should not crash
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});
test.describe("Live Context Menu @critical", () => {
test("right-click on camera opens context menu (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const cameraCard = frigateApp.page
.locator("[data-camera='front_door']")
.first();
await cameraCard.waitFor({ state: "visible", timeout: 10_000 });
// Right-click to open context menu
await cameraCard.click({ button: "right" });
// Context menu should appear (Radix ContextMenu renders a portal)
const contextMenu = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content]',
);
await expect(contextMenu.first()).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Live Mobile @critical", () => {
test("mobile shows list layout by default", async ({ frigateApp }) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// On mobile, cameras render in a list (single column)
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// Should have camera elements
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("mobile camera click enters single view", async ({ frigateApp }) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const cameraCard = frigateApp.page
.locator("[data-camera='front_door']")
.first();
await cameraCard.click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
});

View File

@ -0,0 +1,29 @@
/**
* Logs page tests -- MEDIUM tier.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Logs Page @medium", () => {
test("logs page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("logs page has service toggle", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(2000);
// Should have toggle buttons for frigate/go2rtc/nginx services
const toggleGroup = frigateApp.page.locator('[role="group"]');
const count = await toggleGroup.count();
expect(count).toBeGreaterThan(0);
});
test("logs page shows log content", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(2000);
// Should display some text content (our mock returns log lines)
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,198 @@
/**
* Navigation tests -- CRITICAL tier.
*
* Tests sidebar (desktop) and bottombar (mobile) navigation,
* conditional nav items, settings menus, and route transitions.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Navigation @critical", () => {
test("app loads and renders page root", async ({ frigateApp }) => {
await frigateApp.goto("/");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("logo is visible and links to home", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
if (!frigateApp.isMobile) {
// Desktop: logo in sidebar
const logo = base.sidebar.locator('a[href="/"]').first();
await expect(logo).toBeVisible();
}
});
test("Live nav item is active on root path", async ({ frigateApp }) => {
await frigateApp.goto("/");
const liveLink = frigateApp.page.locator('a[href="/"]').first();
await expect(liveLink).toBeVisible();
});
test("navigate to Review page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/review");
await expect(frigateApp.page).toHaveURL(/\/review/);
});
test("navigate to Explore page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/explore");
await expect(frigateApp.page).toHaveURL(/\/explore/);
});
test("navigate to Export page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/export");
await expect(frigateApp.page).toHaveURL(/\/export/);
});
test("all primary nav links are present", async ({ frigateApp }) => {
await frigateApp.goto("/");
// Live, Review, Explore, Export are always present
await expect(frigateApp.page.locator('a[href="/"]').first()).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/review"]').first(),
).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/explore"]').first(),
).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/export"]').first(),
).toBeVisible();
});
test("desktop sidebar is visible on desktop, hidden on mobile", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
if (!frigateApp.isMobile) {
await expect(base.sidebar).toBeVisible();
} else {
await expect(base.sidebar).not.toBeVisible();
}
});
test("navigate between pages without crash", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
const pageRoot = frigateApp.page.locator("#pageRoot");
// Navigate through all main pages in sequence
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 });
// Navigate back to review (not root, to avoid same-route re-render issues)
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
});
test("unknown route redirects to home", async ({ frigateApp }) => {
// Navigate to an unknown route - React Router's catch-all should redirect
await frigateApp.page.goto("/nonexistent-route");
// Wait for React to render and redirect
await frigateApp.page.waitForTimeout(2000);
// Should either be at root or show the page root (app didn't crash)
const url = frigateApp.page.url();
const hasPageRoot = await frigateApp.page
.locator("#pageRoot")
.isVisible()
.catch(() => false);
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
});
test("Faces nav hidden when face_recognition disabled", async ({
frigateApp,
}) => {
// Default config has face_recognition.enabled = false
await frigateApp.goto("/");
await expect(frigateApp.page.locator('a[href="/faces"]')).not.toBeVisible();
});
test("Chat nav hidden when genai model is none", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
// Override config with genai.model = "none" to hide chat
await frigateApp.installDefaults({
config: {
genai: {
enabled: false,
provider: "ollama",
model: "none",
base_url: "",
},
},
});
await frigateApp.goto("/");
await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible();
});
test("Faces nav visible when face_recognition enabled and admin on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
// Re-install with face_recognition enabled
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 and admin on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults({
config: {
genai: { enabled: true, model: "llava" },
},
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/chat"]')).toBeVisible();
});
test("Classification nav visible for admin on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(page.locator('a[href="/classification"]')).toBeVisible();
});
});

View File

@ -0,0 +1,13 @@
/**
* Replay page tests -- LOW tier.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Replay Page @low", () => {
test("replay page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/replay");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});

View File

@ -0,0 +1,58 @@
/**
* Review/Events page tests -- CRITICAL tier.
*
* Tests timeline, filters, event cards, video controls,
* and mobile-specific drawer interactions.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Review Page @critical", () => {
test("review page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("severity toggle group is visible", async ({ frigateApp }) => {
await frigateApp.goto("/review");
// The review page has a toggle group for alert/detection severity
const toggleGroup = frigateApp.page.locator('[role="group"]').first();
await expect(toggleGroup).toBeVisible({ timeout: 10_000 });
});
test("camera filter button is clickable", async ({ frigateApp }) => {
await frigateApp.goto("/review");
// Find a button that opens the camera filter
const filterButtons = frigateApp.page.locator("button");
const count = await filterButtons.count();
expect(count).toBeGreaterThan(0);
// Page should not crash after interaction
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("empty state shows when no events", async ({ frigateApp }) => {
await frigateApp.goto("/review");
// With empty reviews mock, should show some kind of content (not crash)
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("navigate to review from live page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/review");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("review page has interactive controls", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(2000);
// Should have buttons/controls for filtering
const interactive = frigateApp.page.locator(
"button, input, [role='group']",
);
const count = await interactive.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,32 @@
/**
* Settings page tests -- HIGH tier.
*
* Tests the Settings page renders without crash and
* basic navigation between settings sections.
*/
import { test, expect } from "../../fixtures/frigate-test";
test.describe("Settings Page @high", () => {
test("settings page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("settings page has navigation sections", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
// Should have sidebar navigation or section links
const buttons = frigateApp.page.locator("button, a");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
test("settings page shows content", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
// The page should have meaningful content
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,28 @@
/**
* System page tests -- MEDIUM tier.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("System Page @medium", () => {
test("system page renders without crash", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("system page has interactive controls", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(2000);
// Should have buttons for tab switching or other controls
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
test("system page shows metrics content", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
});

64
web/package-lock.json generated
View File

@ -89,6 +89,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.6.2", "@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
@ -1485,6 +1486,22 @@
"url": "https://opencollective.com/unts" "url": "https://opencollective.com/unts"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
@ -11336,6 +11353,53 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",

View File

@ -13,6 +13,10 @@
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"", "prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
"test": "vitest", "test": "vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"e2e:build": "tsc && vite build --base=/",
"e2e": "playwright test --config e2e/playwright.config.ts",
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed",
"i18n:extract": "i18next-cli extract", "i18n:extract": "i18next-cli extract",
"i18n:extract:ci": "i18next-cli extract --ci", "i18n:extract:ci": "i18next-cli extract --ci",
"i18n:status": "i18next-cli status" "i18n:status": "i18next-cli status"
@ -98,6 +102,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.6.2", "@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",