mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-09 16:47:37 +03:00
basic e2e frontend test framework
This commit is contained in:
parent
ed3bebc967
commit
6b24219e48
80
web/e2e/fixtures/frigate-test.ts
Normal file
80
web/e2e/fixtures/frigate-test.ts
Normal 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 };
|
||||
77
web/e2e/fixtures/mock-data/camera-activity.ts
Normal file
77
web/e2e/fixtures/mock-data/camera-activity.ts
Normal 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);
|
||||
}
|
||||
1
web/e2e/fixtures/mock-data/config-snapshot.json
Normal file
1
web/e2e/fixtures/mock-data/config-snapshot.json
Normal file
File diff suppressed because one or more lines are too long
76
web/e2e/fixtures/mock-data/config.ts
Normal file
76
web/e2e/fixtures/mock-data/config.ts
Normal 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);
|
||||
}
|
||||
94
web/e2e/fixtures/mock-data/generate-config-snapshot.py
Normal file
94
web/e2e/fixtures/mock-data/generate-config-snapshot.py
Normal 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()
|
||||
39
web/e2e/fixtures/mock-data/profile.ts
Normal file
39
web/e2e/fixtures/mock-data/profile.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
76
web/e2e/fixtures/mock-data/stats.ts
Normal file
76
web/e2e/fixtures/mock-data/stats.ts
Normal 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
7
web/e2e/global-setup.ts
Normal 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" });
|
||||
}
|
||||
225
web/e2e/helpers/api-mocker.ts
Normal file
225
web/e2e/helpers/api-mocker.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
web/e2e/helpers/ws-mocker.ts
Normal file
125
web/e2e/helpers/ws-mocker.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
82
web/e2e/pages/base.page.ts
Normal file
82
web/e2e/pages/base.page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
56
web/e2e/playwright.config.ts
Normal file
56
web/e2e/playwright.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
70
web/e2e/specs/auth.spec.ts
Normal file
70
web/e2e/specs/auth.spec.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
22
web/e2e/specs/chat.spec.ts
Normal file
22
web/e2e/specs/chat.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
web/e2e/specs/classification.spec.ts
Normal file
18
web/e2e/specs/classification.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
web/e2e/specs/config-editor.spec.ts
Normal file
23
web/e2e/specs/config-editor.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
web/e2e/specs/explore.spec.ts
Normal file
82
web/e2e/specs/explore.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
web/e2e/specs/export.spec.ts
Normal file
31
web/e2e/specs/export.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
web/e2e/specs/face-library.spec.ts
Normal file
19
web/e2e/specs/face-library.spec.ts
Normal 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
194
web/e2e/specs/live.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
29
web/e2e/specs/logs.spec.ts
Normal file
29
web/e2e/specs/logs.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
198
web/e2e/specs/navigation.spec.ts
Normal file
198
web/e2e/specs/navigation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
13
web/e2e/specs/replay.spec.ts
Normal file
13
web/e2e/specs/replay.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
58
web/e2e/specs/review.spec.ts
Normal file
58
web/e2e/specs/review.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
web/e2e/specs/settings/ui-settings.spec.ts
Normal file
32
web/e2e/specs/settings/ui-settings.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
web/e2e/specs/system.spec.ts
Normal file
28
web/e2e/specs/system.spec.ts
Normal 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
64
web/package-lock.json
generated
@ -89,6 +89,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@ -1485,6 +1486,22 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
||||
@ -11336,6 +11353,53 @@
|
||||
"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": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
|
||||
@ -13,6 +13,10 @@
|
||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||
"test": "vitest",
|
||||
"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:ci": "i18next-cli extract --ci",
|
||||
"i18n:status": "i18next-cli status"
|
||||
@ -98,6 +102,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user