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"
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user