Add frontend tests (#22783)

* basic e2e frontend test framework

* improve mock data generation and add test cases

* more cases

* add e2e tests to PR template

* don't generate mock data in PR CI

* satisfy codeql check

* fix flaky system page tab tests by guarding against crashes from incomplete mock stats

* reduce local test runs to 4 workers to match CI
This commit is contained in:
Josh Hawkins 2026-04-06 17:33:28 -05:00 committed by GitHub
parent ed3bebc967
commit c750372586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2790 additions and 0 deletions

View File

@ -50,6 +50,37 @@ jobs:
# run: npm run test
# working-directory: ./web
web_e2e:
name: Web - E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 20.x
- run: npm install
working-directory: ./web
- name: Install Playwright Chromium
run: npx playwright install chromium --with-deps
working-directory: ./web
- name: Build web for E2E
run: npm run e2e:build
working-directory: ./web
- name: Run E2E tests
run: npm run e2e
working-directory: ./web
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
web/test-results/
web/playwright-report/
retention-days: 7
python_checks:
runs-on: ubuntu-latest
name: Python Checks

View File

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

View File

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

View File

@ -0,0 +1 @@
[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1775407931.3863528, "updated_at": 1775483531.3863528}]

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1 @@
[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1775487131.3863528, "end_time": 1775487161.3863528, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1775483531.3863528, "end_time": 1775483576.3863528, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1775479931.3863528, "end_time": 1775479951.3863528, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}]

View File

@ -0,0 +1 @@
[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}]

View File

@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""Generate E2E mock data from backend Pydantic and Peewee models.
Run from the repo root:
PYTHONPATH=/workspace/frigate python3 web/e2e/fixtures/mock-data/generate-mock-data.py
Strategy:
- FrigateConfig: instantiate the Pydantic config model, then model_dump()
- API responses: instantiate Pydantic response models (ReviewSegmentResponse,
EventResponse, ExportModel, ExportCaseModel) to validate all required fields
- If the backend adds a required field, this script fails at instantiation time
- The Peewee model field list is checked to detect new columns that would
appear in .dicts() API responses but aren't in our mock data
"""
import json
import sys
import time
import warnings
from datetime import datetime, timedelta
from pathlib import Path
warnings.filterwarnings("ignore")
OUTPUT_DIR = Path(__file__).parent
NOW = time.time()
HOUR = 3600
CAMERAS = ["front_door", "backyard", "garage"]
def check_pydantic_fields(pydantic_class, mock_keys, model_name):
"""Verify mock data covers all fields declared in the Pydantic response model.
The Pydantic response model is what the frontend actually receives.
Peewee models may have extra legacy columns that are filtered out by
FastAPI's response_model validation.
"""
required_fields = set()
for name, field_info in pydantic_class.model_fields.items():
required_fields.add(name)
missing = required_fields - mock_keys
if missing:
print(
f" ERROR: {model_name} response model has fields not in mock data: {missing}",
file=sys.stderr,
)
print(
f" Add these fields to the mock data in this script.",
file=sys.stderr,
)
sys.exit(1)
extra = mock_keys - required_fields
if extra:
print(
f" NOTE: {model_name} mock data has extra fields (not in response model): {extra}",
)
def generate_config():
"""Generate FrigateConfig from the Python backend model."""
from frigate.config import FrigateConfig
config = FrigateConfig.model_validate_json(
json.dumps(
{
"mqtt": {"host": "mqtt"},
"cameras": {
cam: {
"ffmpeg": {
"inputs": [
{
"path": f"rtsp://10.0.0.{i+1}:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 720, "width": 1280, "fps": 5},
}
for i, cam in enumerate(CAMERAS)
},
"camera_groups": {
"default": {
"cameras": CAMERAS,
"icon": "generic",
"order": 0,
},
"outdoor": {
"cameras": ["front_door", "backyard"],
"icon": "generic",
"order": 1,
},
},
}
)
)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
snapshot = config.model_dump()
# Runtime-computed fields not in the Pydantic dump
all_attrs = set()
for attrs in snapshot.get("model", {}).get("attributes_map", {}).values():
all_attrs.update(attrs)
snapshot["model"]["all_attributes"] = sorted(all_attrs)
snapshot["model"]["colormap"] = {}
return snapshot
def generate_reviews():
"""Generate ReviewSegmentResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.review_response import ReviewSegmentResponse
reviews = [
ReviewSegmentResponse(
id="review-alert-001",
camera="front_door",
severity="alert",
start_time=datetime.fromtimestamp(NOW - 2 * HOUR),
end_time=datetime.fromtimestamp(NOW - 2 * HOUR + 30),
has_been_reviewed=False,
thumb_path="/clips/front_door/review-alert-001-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["person-abc123"],
"objects": ["person"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["front_yard"],
}
),
),
ReviewSegmentResponse(
id="review-alert-002",
camera="backyard",
severity="alert",
start_time=datetime.fromtimestamp(NOW - 3 * HOUR),
end_time=datetime.fromtimestamp(NOW - 3 * HOUR + 45),
has_been_reviewed=True,
thumb_path="/clips/backyard/review-alert-002-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["car-def456"],
"objects": ["car"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["driveway"],
}
),
),
ReviewSegmentResponse(
id="review-detect-001",
camera="garage",
severity="detection",
start_time=datetime.fromtimestamp(NOW - 4 * HOUR),
end_time=datetime.fromtimestamp(NOW - 4 * HOUR + 20),
has_been_reviewed=False,
thumb_path="/clips/garage/review-detect-001-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["person-ghi789"],
"objects": ["person"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": [],
}
),
),
ReviewSegmentResponse(
id="review-detect-002",
camera="front_door",
severity="detection",
start_time=datetime.fromtimestamp(NOW - 5 * HOUR),
end_time=datetime.fromtimestamp(NOW - 5 * HOUR + 15),
has_been_reviewed=False,
thumb_path="/clips/front_door/review-detect-002-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["car-jkl012"],
"objects": ["car"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["front_yard"],
}
),
),
]
result = [r.model_dump(mode="json") for r in reviews]
# Verify mock data covers all Pydantic response model fields
check_pydantic_fields(
ReviewSegmentResponse, set(result[0].keys()), "ReviewSegment"
)
return result
def generate_events():
"""Generate EventResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.event_response import EventResponse
events = [
EventResponse(
id="event-person-001",
label="person",
sub_label=None,
camera="front_door",
start_time=NOW - 2 * HOUR,
end_time=NOW - 2 * HOUR + 30,
false_positive=False,
zones=["front_yard"],
thumbnail=None,
has_clip=True,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="abc123",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.92,
"score": 0.92,
"region": [0.1, 0.1, 0.5, 0.8],
"box": [0.2, 0.15, 0.45, 0.75],
"area": 0.18,
"ratio": 0.6,
"type": "object",
"description": "A person walking toward the front door",
"average_estimated_speed": 1.2,
"velocity_angle": 45.0,
"path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]],
},
),
EventResponse(
id="event-car-001",
label="car",
sub_label=None,
camera="backyard",
start_time=NOW - 3 * HOUR,
end_time=NOW - 3 * HOUR + 45,
false_positive=False,
zones=["driveway"],
thumbnail=None,
has_clip=True,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="def456",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.87,
"score": 0.87,
"region": [0.3, 0.2, 0.9, 0.7],
"box": [0.35, 0.25, 0.85, 0.65],
"area": 0.2,
"ratio": 1.25,
"type": "object",
"description": "A car parked in the driveway",
"average_estimated_speed": 0.0,
"velocity_angle": 0.0,
"path_data": [],
},
),
EventResponse(
id="event-person-002",
label="person",
sub_label=None,
camera="garage",
start_time=NOW - 4 * HOUR,
end_time=NOW - 4 * HOUR + 20,
false_positive=False,
zones=[],
thumbnail=None,
has_clip=False,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="ghi789",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.78,
"score": 0.78,
"region": [0.0, 0.0, 0.6, 0.9],
"box": [0.1, 0.05, 0.5, 0.85],
"area": 0.32,
"ratio": 0.5,
"type": "object",
"description": None,
"average_estimated_speed": 0.5,
"velocity_angle": 90.0,
"path_data": [[[0.1, 0.4], 0.0]],
},
),
]
result = [e.model_dump(mode="json") for e in events]
check_pydantic_fields(EventResponse, set(result[0].keys()), "Event")
return result
def generate_exports():
"""Generate ExportModel[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.export_response import ExportModel
exports = [
ExportModel(
id="export-001",
camera="front_door",
name="Front Door - Person Alert",
date=NOW - 1 * HOUR,
video_path="/exports/export-001.mp4",
thumb_path="/exports/export-001-thumb.jpg",
in_progress=False,
export_case_id=None,
),
ExportModel(
id="export-002",
camera="backyard",
name="Backyard - Car Detection",
date=NOW - 3 * HOUR,
video_path="/exports/export-002.mp4",
thumb_path="/exports/export-002-thumb.jpg",
in_progress=False,
export_case_id="case-001",
),
ExportModel(
id="export-003",
camera="garage",
name="Garage - In Progress",
date=NOW - 0.5 * HOUR,
video_path="/exports/export-003.mp4",
thumb_path="/exports/export-003-thumb.jpg",
in_progress=True,
export_case_id=None,
),
]
result = [e.model_dump(mode="json") for e in exports]
check_pydantic_fields(ExportModel, set(result[0].keys()), "Export")
return result
def generate_cases():
"""Generate ExportCaseModel[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.export_case_response import ExportCaseModel
cases = [
ExportCaseModel(
id="case-001",
name="Package Theft Investigation",
description="Review of suspicious activity near the front porch",
created_at=NOW - 24 * HOUR,
updated_at=NOW - 3 * HOUR,
),
]
result = [c.model_dump(mode="json") for c in cases]
check_pydantic_fields(ExportCaseModel, set(result[0].keys()), "ExportCase")
return result
def generate_review_summary():
"""Generate ReviewSummary for the calendar filter."""
today = datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
return {
today: {
"day": today,
"reviewed_alert": 1,
"reviewed_detection": 0,
"total_alert": 2,
"total_detection": 2,
},
yesterday: {
"day": yesterday,
"reviewed_alert": 3,
"reviewed_detection": 2,
"total_alert": 3,
"total_detection": 4,
},
}
def write_json(filename, data):
path = OUTPUT_DIR / filename
path.write_text(json.dumps(data, default=str))
print(f" {path.name} ({path.stat().st_size} bytes)")
def main():
print("Generating E2E mock data from backend models...")
print(" Validating against Pydantic response models + Peewee DB columns")
print()
write_json("config-snapshot.json", generate_config())
write_json("reviews.json", generate_reviews())
write_json("events.json", generate_events())
write_json("exports.json", generate_exports())
write_json("cases.json", generate_cases())
write_json("review-summary.json", generate_review_summary())
print()
print("All mock data validated against backend schemas.")
print("If this script fails, update the mock data to match the new schema.")
if __name__ == "__main__":
main()

View File

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

View File

@ -0,0 +1 @@
{"2026-04-06": {"day": "2026-04-06", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-04-05": {"day": "2026-04-05", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}}

View File

@ -0,0 +1 @@
[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-04-06T09:52:11.386353", "end_time": "2026-04-06T09:52:41.386353", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-04-06T08:52:11.386353", "end_time": "2026-04-06T08:52:56.386353", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-04-06T07:52:11.386353", "end_time": "2026-04-06T07:52:31.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-04-06T06:52:11.386353", "end_time": "2026-04-06T06:52:26.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}]

View File

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

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

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

View File

@ -0,0 +1,271 @@
/**
* 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 { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
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";
const __dirname = dirname(fileURLToPath(import.meta.url));
const MOCK_DATA_DIR = resolve(__dirname, "../fixtures/mock-data");
function loadMockJson(filename: string): unknown {
return JSON.parse(readFileSync(resolve(MOCK_DATA_DIR, filename), "utf-8"));
}
// 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);
const reviews =
overrides?.reviews ?? (loadMockJson("reviews.json") as unknown[]);
const events =
overrides?.events ?? (loadMockJson("events.json") as unknown[]);
const exports =
overrides?.exports ?? (loadMockJson("exports.json") as unknown[]);
const cases = overrides?.cases ?? (loadMockJson("cases.json") as unknown[]);
const reviewSummary = loadMockJson("review-summary.json");
// 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) => {
const url = route.request().url();
if (url.includes("summary")) {
return route.fulfill({ json: reviewSummary });
}
return route.fulfill({ json: reviews });
});
// Recordings summary
await this.page.route("**/api/recordings/summary**", (route) =>
route.fulfill({ json: {} }),
);
// Previews (needed for review page event cards)
await this.page.route("**/api/preview/**", (route) =>
route.fulfill({ json: [] }),
);
// Sub-labels and attributes (for explore filters)
await this.page.route("**/api/sub_labels", (route) =>
route.fulfill({ json: [] }),
);
await this.page.route("**/api/labels", (route) =>
route.fulfill({ json: ["person", "car"] }),
);
await this.page.route("**/api/*/attributes", (route) =>
route.fulfill({ json: [] }),
);
await this.page.route("**/api/recognized_license_plates", (route) =>
route.fulfill({ json: [] }),
);
// Events / search
await this.page.route("**/api/events**", (route) =>
route.fulfill({ json: events }),
);
// Exports
await this.page.route("**/api/export**", (route) =>
route.fulfill({ json: exports }),
);
// Cases
await this.page.route("**/api/cases", (route) =>
route.fulfill({ json: cases }),
);
// Faces
await this.page.route("**/api/faces", (route) =>
route.fulfill({ json: overrides?.faces ?? {} }),
);
// Logs
await this.page.route("**/api/logs/**", (route) =>
route.fulfill({
contentType: "text/plain",
body: "[2026-04-06 10:00:00] INFO: Frigate started\n[2026-04-06 10:00:01] INFO: Cameras loaded\n",
}),
);
// Config raw
await this.page.route("**/api/config/raw", (route) =>
route.fulfill({
contentType: "text/plain",
body:
overrides?.configRaw ??
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n",
}),
);
// Config schema
await this.page.route("**/api/config/schema.json", (route) =>
route.fulfill({
json: overrides?.configSchema ?? { type: "object", properties: {} },
}),
);
// Config set (mutation)
await this.page.route("**/api/config/set", (route) =>
route.fulfill({ json: { success: true, require_restart: false } }),
);
// Go2RTC streams
await this.page.route("**/api/go2rtc/streams**", (route) =>
route.fulfill({ json: {} }),
);
// Profiles
await this.page.route("**/api/profiles**", (route) =>
route.fulfill({
json: { profiles: [], active_profile: null, last_activated: {} },
}),
);
// Motion search
await this.page.route("**/api/motion_search**", (route) =>
route.fulfill({ json: { job_id: "test-job" } }),
);
// Region grid
await this.page.route("**/api/*/region_grid", (route) =>
route.fulfill({ json: {} }),
);
// Debug replay
await this.page.route("**/api/debug_replay/**", (route) =>
route.fulfill({ json: {} }),
);
// Generic mutation catch-all for remaining endpoints.
// Uses route.fallback() to defer to more specific routes registered above.
// Playwright matches routes in reverse registration order (last wins),
// so this catch-all must use fallback() to let specific routes take precedence.
await this.page.route("**/api/**", (route) => {
const method = route.request().method();
if (
method === "POST" ||
method === "PUT" ||
method === "PATCH" ||
method === "DELETE"
) {
return route.fulfill({ json: { success: true } });
}
// Fall through to more specific routes for GET requests
return route.fallback();
});
}
}
export class MediaMocker {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async install() {
// Camera snapshots
await this.page.route("**/api/*/latest.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Clips and thumbnails
await this.page.route("**/clips/**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Event thumbnails
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// Event snapshots
await this.page.route("**/api/events/*/snapshot.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
}),
);
// VOD / recordings
await this.page.route("**/vod/**", (route) =>
route.fulfill({
contentType: "application/vnd.apple.mpegurl",
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
}),
);
// Live streams
await this.page.route("**/live/**", (route) =>
route.fulfill({
contentType: "application/vnd.apple.mpegurl",
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
}),
);
}
}

View File

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

View File

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

View File

@ -0,0 +1,56 @@
import { defineConfig, devices } from "@playwright/test";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const webRoot = resolve(__dirname, "..");
const DESKTOP_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const MOBILE_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
export default defineConfig({
testDir: "./specs",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 4,
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,
},
},
],
});

147
web/e2e/specs/auth.spec.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* Auth and cross-cutting tests -- HIGH tier.
*
* Tests protected route access for admin/viewer roles,
* access denied page rendering, viewer nav restrictions,
* and all routes smoke test.
*/
import { test, expect } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth - Admin Access @high", () => {
test("admin can access /system and sees system tabs", async ({
frigateApp,
}) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await frigateApp.page.waitForTimeout(3000);
// System page should have named tab buttons
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 5_000,
});
});
test("admin can access /config and Monaco editor loads", async ({
frigateApp,
}) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
const editor = frigateApp.page.locator(
".monaco-editor, [data-keybinding-context]",
);
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
});
test("admin can access /logs and sees service tabs", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
test("admin sees Classification nav on desktop", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(
frigateApp.page.locator('a[href="/classification"]'),
).toBeVisible();
});
});
test.describe("Auth - Viewer Restrictions @high", () => {
test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/system");
await page.waitForTimeout(2000);
// Should show "Access Denied" text
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer sees Access Denied on /config", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/config");
await page.waitForTimeout(2000);
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer sees Access Denied on /logs", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/logs");
await page.waitForTimeout(2000);
await expect(page.getByText("Access Denied")).toBeVisible({
timeout: 5_000,
});
});
test("viewer can access Live page and sees cameras", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/");
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(page.locator("[data-camera='front_door']")).toBeVisible({
timeout: 10_000,
});
});
test("viewer can access Review page and sees severity tabs", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/review");
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(page.getByLabel("Alerts")).toBeVisible({ timeout: 5_000 });
});
test("viewer can access all main user routes without crash", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
const routes = ["/", "/review", "/explore", "/export", "/settings"];
for (const route of routes) {
await page.goto(route);
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
}
});
});
test.describe("Auth - All Routes Smoke @high", () => {
test("all user routes render without crash", async ({ frigateApp }) => {
const routes = ["/", "/review", "/explore", "/export", "/settings"];
for (const route of routes) {
await frigateApp.goto(route);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
}
});
test("admin routes render with specific content", async ({ frigateApp }) => {
// System page should have tab controls
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(3000);
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 5_000,
});
// Logs page should have service tabs
await frigateApp.goto("/logs");
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
});

View File

@ -0,0 +1,34 @@
/**
* Chat page tests -- MEDIUM tier.
*
* Tests chat interface rendering, input area, and example prompt buttons.
*/
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 input or buttons", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
const interactive = frigateApp.page.locator("input, textarea, button");
const count = await interactive.count();
expect(count).toBeGreaterThan(0);
});
test("chat input accepts text", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
const input = frigateApp.page.locator("input, textarea").first();
if (await input.isVisible().catch(() => false)) {
await input.fill("What cameras detected a person today?");
const value = await input.inputValue();
expect(value.length).toBeGreaterThan(0);
}
});
});

View File

@ -0,0 +1,33 @@
/**
* Classification page tests -- MEDIUM tier.
*
* Tests model selection view rendering and interactive elements.
*/
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 and controls", async ({
frigateApp,
}) => {
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
test("classification page has interactive elements", async ({
frigateApp,
}) => {
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@ -0,0 +1,44 @@
/**
* Config Editor page tests -- MEDIUM tier.
*
* Tests Monaco editor loading, YAML content rendering,
* save button presence, and copy button interaction.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Config Editor @medium", () => {
test("config editor loads Monaco editor with content", async ({
frigateApp,
}) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
// Monaco editor should render with a specific class
const editor = frigateApp.page.locator(
".monaco-editor, [data-keybinding-context]",
);
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
});
test("config editor has action buttons", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
test("config editor button clicks do not crash", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
// Find buttons with SVG icons (copy, save, etc.)
const iconButtons = frigateApp.page.locator("button:has(svg)");
const count = await iconButtons.count();
if (count > 0) {
// Click the first icon button (likely copy)
await iconButtons.first().click();
await frigateApp.page.waitForTimeout(500);
}
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});

View File

@ -0,0 +1,97 @@
/**
* Explore page tests -- HIGH tier.
*
* Tests search input with text entry and clearing, camera filter popover
* opening with camera names, and content rendering with mock events.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Explore Page - Search @high", () => {
test("explore page renders with filter buttons", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const buttons = frigateApp.page.locator("#pageRoot button");
await expect(buttons.first()).toBeVisible({ timeout: 10_000 });
});
test("search input accepts text and can be cleared", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await searchInput.fill("person");
await expect(searchInput).toHaveValue("person");
await searchInput.fill("");
await expect(searchInput).toHaveValue("");
}
});
test("search input submits on Enter", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await searchInput.fill("car in driveway");
await searchInput.press("Enter");
await frigateApp.page.waitForTimeout(1000);
// Page should not crash after search submit
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
}
});
});
test.describe("Explore Page - Filters @high", () => {
test("camera filter button opens popover with camera names (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
if (await camerasBtn.isVisible().catch(() => false)) {
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Camera names from config should be in the popover
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
}
});
test("filter button opens and closes overlay cleanly", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
const firstButton = frigateApp.page.locator("#pageRoot button").first();
await expect(firstButton).toBeVisible({ timeout: 5_000 });
await firstButton.click();
await frigateApp.page.waitForTimeout(500);
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
// Page is still functional after open/close cycle
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Explore Page - Content @high", () => {
test("explore page shows content with mock events", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(3000);
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,74 @@
/**
* Export page tests -- HIGH tier.
*
* Tests export card rendering with mock data, search filtering,
* and delete confirmation dialog.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Export Page - Cards @high", () => {
test("export page renders export cards from mock data", async ({
frigateApp,
}) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// Should show export names from our mock data
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible({ timeout: 10_000 });
await expect(
frigateApp.page.getByText("Backyard - Car Detection"),
).toBeVisible();
});
test("export page shows in-progress indicator", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// "Garage - In Progress" export should be visible
await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible(
{ timeout: 10_000 },
);
});
test("export page shows case grouping", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(3000);
// Cases may render differently depending on API response shape
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
});
});
test.describe("Export Page - Search @high", () => {
test("search input filters export list", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
const searchInput = frigateApp.page.locator(
'#pageRoot input[type="text"], #pageRoot input',
);
if (
(await searchInput.count()) > 0 &&
(await searchInput.first().isVisible())
) {
// Type a search term that matches one export
await searchInput.first().fill("Front Door");
await frigateApp.page.waitForTimeout(500);
// "Front Door - Person Alert" should still be visible
await expect(
frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible();
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Export Page - Controls @high", () => {
test("export page filter controls are present", async ({ frigateApp }) => {
await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(1000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,32 @@
/**
* Face Library page tests -- MEDIUM tier.
*
* Tests face grid rendering, empty state, and interactive controls.
*/
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 with no faces", async ({
frigateApp,
}) => {
await frigateApp.goto("/faces");
await frigateApp.page.waitForTimeout(2000);
// With empty faces mock, should show empty state or content
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
test("face library has interactive buttons", async ({ frigateApp }) => {
await frigateApp.goto("/faces");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

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

@ -0,0 +1,253 @@
/**
* Live page tests -- CRITICAL tier.
*
* Tests camera dashboard rendering, camera card clicks, single camera view
* with named controls, feature toggle behavior, context menu, and mobile layout.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Live Dashboard @critical", () => {
test("dashboard renders all configured cameras by name", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
for (const cam of ["front_door", "backyard", "garage"]) {
await expect(
frigateApp.page.locator(`[data-camera='${cam}']`),
).toBeVisible({ timeout: 10_000 });
}
});
test("clicking camera card opens single camera view via hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
test("back button returns from single camera to dashboard", async ({
frigateApp,
}) => {
// First navigate to dashboard so there's history to go back to
await frigateApp.goto("/");
await frigateApp.page.waitForTimeout(1000);
// Click a camera to enter single view
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await frigateApp.page.waitForTimeout(2000);
// Now click Back to return to dashboard
const backBtn = frigateApp.page.getByText("Back", { exact: true });
if (await backBtn.isVisible().catch(() => false)) {
await backBtn.click();
await frigateApp.page.waitForTimeout(1000);
}
// Should be back on the dashboard with cameras visible
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("birdseye view loads without crash", async ({ frigateApp }) => {
await frigateApp.goto("/#birdseye");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("empty group shows fallback content", async ({ frigateApp }) => {
await frigateApp.page.goto("/?group=nonexistent");
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Live Single Camera - Controls @critical", () => {
test("single camera view shows Back and History buttons (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip(); // On mobile, buttons may show icons only
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Back and History are visible text buttons in the header
await expect(
frigateApp.page.getByText("Back", { exact: true }),
).toBeVisible({ timeout: 5_000 });
await expect(
frigateApp.page.getByText("History", { exact: true }),
).toBeVisible();
});
test("single camera view shows feature toggle icons (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Feature toggles are CameraFeatureToggle components rendered as divs
// with bg-selected (active) or bg-secondary (inactive) classes
// Count the toggles - should have at least detect, recording, snapshots
const toggles = frigateApp.page.locator(
".flex.flex-col.items-center.justify-center.bg-selected, .flex.flex-col.items-center.justify-center.bg-secondary",
);
const count = await toggles.count();
expect(count).toBeGreaterThanOrEqual(3);
});
test("clicking a feature toggle changes its visual state (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Find active toggles (bg-selected class = feature is ON)
const activeToggles = frigateApp.page.locator(
".flex.flex-col.items-center.justify-center.bg-selected",
);
const initialCount = await activeToggles.count();
if (initialCount > 0) {
// Click the first active toggle to disable it
await activeToggles.first().click();
await frigateApp.page.waitForTimeout(1000);
// After WS mock echoes back new state, count should decrease
const newCount = await activeToggles.count();
expect(newCount).toBeLessThan(initialCount);
}
});
test("settings gear button opens dropdown (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// Find the gear icon button (last button-like element in header)
// The settings gear opens a dropdown with Stream, Play in background, etc.
const gearButtons = frigateApp.page.locator("button:has(svg)");
const count = await gearButtons.count();
// Click the last one (gear icon is typically last in the header)
if (count > 0) {
await gearButtons.last().click();
await frigateApp.page.waitForTimeout(500);
// A dropdown or drawer should appear
const overlay = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content], [role="dialog"]',
);
const visible = await overlay
.first()
.isVisible()
.catch(() => false);
if (visible) {
await frigateApp.page.keyboard.press("Escape");
}
}
});
test("keyboard shortcut f does not crash on desktop", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await frigateApp.page.keyboard.press("f");
await frigateApp.page.waitForTimeout(500);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});
test.describe("Live Single Camera - Mobile Controls @critical", () => {
test("mobile camera view has settings drawer trigger", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/#front_door");
await frigateApp.page.waitForTimeout(2000);
// On mobile, settings gear opens a drawer
// The button has aria-label with the camera name like "front_door Settings"
const buttons = frigateApp.page.locator("button:has(svg)");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe("Live Context Menu @critical", () => {
test("right-click on camera opens context menu on desktop", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.waitFor({ state: "visible", timeout: 10_000 });
await card.click({ button: "right" });
const contextMenu = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content]',
);
await expect(contextMenu.first()).toBeVisible({ timeout: 5_000 });
});
test("context menu closes on escape", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.waitFor({ state: "visible", timeout: 10_000 });
await card.click({ button: "right" });
await frigateApp.page.waitForTimeout(500);
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
const contextMenu = frigateApp.page.locator(
'[role="menu"], [data-radix-menu-content]',
);
await expect(contextMenu).not.toBeVisible();
});
});
test.describe("Live Mobile Layout @critical", () => {
test("mobile renders cameras without sidebar", async ({ frigateApp }) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(frigateApp.page.locator("aside")).not.toBeVisible();
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("mobile camera click opens single camera view", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const card = frigateApp.page.locator("[data-camera='front_door']").first();
await card.click({ timeout: 10_000 });
await expect(frigateApp.page).toHaveURL(/#front_door/);
});
});

View File

@ -0,0 +1,75 @@
/**
* Logs page tests -- MEDIUM tier.
*
* Tests service tab switching by name, copy/download buttons,
* and websocket message feed tab.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Logs Page - Service Tabs @medium", () => {
test("logs page renders with named service tabs", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// Service tabs have aria-label="Select {service}"
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
timeout: 5_000,
});
});
test("switching to go2rtc tab changes active tab", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
if (await go2rtcTab.isVisible().catch(() => false)) {
await go2rtcTab.click();
await frigateApp.page.waitForTimeout(1000);
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
}
});
test("switching to websocket tab shows message feed", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const wsTab = frigateApp.page.getByLabel("Select websocket");
if (await wsTab.isVisible().catch(() => false)) {
await wsTab.click();
await frigateApp.page.waitForTimeout(1000);
await expect(wsTab).toHaveAttribute("data-state", "on");
}
});
});
test.describe("Logs Page - Actions @medium", () => {
test("copy to clipboard button is present and clickable", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
if (await copyBtn.isVisible().catch(() => false)) {
await copyBtn.click();
await frigateApp.page.waitForTimeout(500);
// Should trigger clipboard copy (toast may appear)
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("download logs button is present", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(1000);
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
if (await downloadBtn.isVisible().catch(() => false)) {
await expect(downloadBtn).toBeVisible();
}
});
test("logs page displays log content text", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,227 @@
/**
* Navigation tests -- CRITICAL tier.
*
* Tests sidebar (desktop) and bottombar (mobile) navigation,
* conditional nav items, settings menus, and their actual behaviors.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Navigation @critical", () => {
test("app loads and renders page root", async ({ frigateApp }) => {
await frigateApp.goto("/");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("logo is visible and links to home", async ({ frigateApp }) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, true);
const logo = base.sidebar.locator('a[href="/"]').first();
await expect(logo).toBeVisible();
});
test("all primary nav links are present and navigate", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const routes = ["/review", "/explore", "/export"];
for (const route of routes) {
await expect(
frigateApp.page.locator(`a[href="${route}"]`).first(),
).toBeVisible();
}
// Verify clicking each one actually navigates
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
for (const route of routes) {
await base.navigateTo(route);
await expect(frigateApp.page).toHaveURL(new RegExp(route));
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
}
});
test("desktop sidebar is visible, mobile bottombar is visible", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
if (!frigateApp.isMobile) {
await expect(base.sidebar).toBeVisible();
} else {
await expect(base.sidebar).not.toBeVisible();
}
});
test("navigate between all main pages without crash", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
const pageRoot = frigateApp.page.locator("#pageRoot");
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/explore");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/export");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
});
test("unknown route redirects to home", async ({ frigateApp }) => {
await frigateApp.page.goto("/nonexistent-route");
await frigateApp.page.waitForTimeout(2000);
const url = frigateApp.page.url();
const hasPageRoot = await frigateApp.page
.locator("#pageRoot")
.isVisible()
.catch(() => false);
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
});
});
test.describe("Navigation - Conditional Items @critical", () => {
test("Faces nav hidden when face_recognition disabled", async ({
frigateApp,
}) => {
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;
}
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 on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/faces"]')).toBeVisible();
});
test("Chat nav visible when genai model set on desktop", async ({
frigateApp,
page,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
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();
});
});
test.describe("Navigation - Settings Menu @critical", () => {
test("settings gear opens menu with navigation items (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// Settings gear is in the sidebar bottom section, a div with cursor-pointer
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const gearIcon = sidebarBottom
.locator("div[class*='cursor-pointer']")
.first();
await expect(gearIcon).toBeVisible({ timeout: 5_000 });
await gearIcon.click();
// Menu should open - look for the "Settings" menu item by aria-label
await expect(frigateApp.page.getByLabel("Settings")).toBeVisible({
timeout: 3_000,
});
});
test("settings menu items navigate to correct routes (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const targets = [
{ label: "Settings", url: "/settings" },
{ label: "System metrics", url: "/system" },
{ label: "System logs", url: "/logs" },
{ label: "Configuration Editor", url: "/config" },
];
for (const target of targets) {
await frigateApp.goto("/");
const gearIcon = frigateApp.page
.locator("aside .mb-8 div[class*='cursor-pointer']")
.first();
await gearIcon.click();
await frigateApp.page.waitForTimeout(300);
const menuItem = frigateApp.page.getByLabel(target.label);
if (await menuItem.isVisible().catch(() => false)) {
await menuItem.click();
await expect(frigateApp.page).toHaveURL(
new RegExp(target.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
}
}
});
test("account button in sidebar is clickable (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const items = sidebarBottom.locator("div[class*='cursor-pointer']");
const count = await items.count();
if (count >= 2) {
await items.nth(1).click();
await frigateApp.page.waitForTimeout(500);
}
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});

View File

@ -0,0 +1,23 @@
/**
* Replay page tests -- LOW tier.
*
* Tests replay page rendering and basic interactivity.
*/
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();
});
test("replay page has interactive controls", async ({ frigateApp }) => {
await frigateApp.goto("/replay");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,200 @@
/**
* Review/Events page tests -- CRITICAL tier.
*
* Tests severity tab switching by name (Alerts/Detections/Motion),
* filter popover opening with camera names, show reviewed toggle,
* calendar button, and filter button interactions.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Review Page - Severity Tabs @critical", () => {
test("severity tabs render with Alerts, Detections, Motion", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
});
await expect(frigateApp.page.getByLabel("Detections")).toBeVisible();
// Motion uses role="radio" to distinguish from other Motion elements
await expect(
frigateApp.page.getByRole("radio", { name: "Motion" }),
).toBeVisible();
});
test("Alerts tab is active by default", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const alertsTab = frigateApp.page.getByLabel("Alerts");
await expect(alertsTab).toHaveAttribute("data-state", "on");
});
test("clicking Detections tab makes it active and deactivates Alerts", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const alertsTab = frigateApp.page.getByLabel("Alerts");
const detectionsTab = frigateApp.page.getByLabel("Detections");
await detectionsTab.click();
await frigateApp.page.waitForTimeout(500);
await expect(detectionsTab).toHaveAttribute("data-state", "on");
await expect(alertsTab).toHaveAttribute("data-state", "off");
});
test("clicking Motion tab makes it active", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" });
await motionTab.click();
await frigateApp.page.waitForTimeout(500);
await expect(motionTab).toHaveAttribute("data-state", "on");
});
test("switching back to Alerts from Detections works", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
await frigateApp.page.getByLabel("Detections").click();
await frigateApp.page.waitForTimeout(300);
await frigateApp.page.getByLabel("Alerts").click();
await frigateApp.page.waitForTimeout(300);
await expect(frigateApp.page.getByLabel("Alerts")).toHaveAttribute(
"data-state",
"on",
);
});
});
test.describe("Review Page - Filters @critical", () => {
test("All Cameras filter button opens popover with camera names", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover should open with camera names from config
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Camera names should be present
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
await frigateApp.page.keyboard.press("Escape");
});
test("Show Reviewed toggle is clickable", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const showReviewed = frigateApp.page.getByRole("button", {
name: /reviewed/i,
});
if (await showReviewed.isVisible().catch(() => false)) {
await showReviewed.click();
await frigateApp.page.waitForTimeout(500);
// Toggle should change state
await expect(frigateApp.page.locator("body")).toBeVisible();
}
});
test("Last 24 Hours calendar button opens date picker", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const calendarBtn = frigateApp.page.getByRole("button", {
name: /24 hours|calendar|date/i,
});
if (await calendarBtn.isVisible().catch(() => false)) {
await calendarBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover should open
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
if (
await popover
.first()
.isVisible()
.catch(() => false)
) {
await frigateApp.page.keyboard.press("Escape");
}
}
});
test("Filter button opens filter popover", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
const filterBtn = frigateApp.page.getByRole("button", {
name: /^filter$/i,
});
if (await filterBtn.isVisible().catch(() => false)) {
await filterBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover or dialog should open
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper], [role='dialog']",
);
if (
await popover
.first()
.isVisible()
.catch(() => false)
) {
await frigateApp.page.keyboard.press("Escape");
}
}
});
});
test.describe("Review Page - Timeline @critical", () => {
test("review page has timeline with time markers (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(2000);
// Timeline renders time labels like "4:30 PM"
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText).toMatch(/[AP]M/);
});
});
test.describe("Review Page - Navigation @critical", () => {
test("navigate to review from live page works", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/review");
await expect(frigateApp.page).toHaveURL(/\/review/);
// Severity tabs should be visible
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
});
});
});

View File

@ -0,0 +1,40 @@
/**
* Settings page tests -- HIGH tier.
*
* Tests settings page rendering with content, form controls,
* and section navigation.
*/
import { test, expect } from "../../fixtures/frigate-test";
test.describe("Settings Page @high", () => {
test("settings page renders with content", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
test("settings page has clickable navigation items", async ({
frigateApp,
}) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
const navItems = frigateApp.page.locator(
"#pageRoot button, #pageRoot [role='button'], #pageRoot a",
);
const count = await navItems.count();
expect(count).toBeGreaterThan(0);
});
test("settings page has form controls", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
const formElements = frigateApp.page.locator(
'#pageRoot input, #pageRoot button[role="switch"], #pageRoot button[role="combobox"]',
);
const count = await formElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@ -0,0 +1,90 @@
/**
* System page tests -- MEDIUM tier.
*
* Tests system page rendering with tabs and tab switching.
* Navigates to /system#general explicitly so useHashState resolves
* the tab state deterministically.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("System Page @medium", () => {
test("system page renders with tab buttons", async ({ frigateApp }) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
});
test("general tab is active when navigated via hash", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
});
test("clicking Storage tab activates it and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select storage").click();
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"off",
);
});
test("clicking Cameras tab activates it and deactivates General", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"off",
);
});
test("system page shows version and last refreshed", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
});
});

64
web/package-lock.json generated
View File

@ -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",

View File

@ -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",