mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-09 08:37:37 +03:00
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:
parent
ed3bebc967
commit
c750372586
31
.github/workflows/pull_request.yml
vendored
31
.github/workflows/pull_request.yml
vendored
@ -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
|
||||
|
||||
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/cases.json
Normal file
1
web/e2e/fixtures/mock-data/cases.json
Normal 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}]
|
||||
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);
|
||||
}
|
||||
1
web/e2e/fixtures/mock-data/events.json
Normal file
1
web/e2e/fixtures/mock-data/events.json
Normal 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]]}}]
|
||||
1
web/e2e/fixtures/mock-data/exports.json
Normal file
1
web/e2e/fixtures/mock-data/exports.json
Normal 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}]
|
||||
426
web/e2e/fixtures/mock-data/generate-mock-data.py
Normal file
426
web/e2e/fixtures/mock-data/generate-mock-data.py
Normal 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()
|
||||
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,
|
||||
};
|
||||
}
|
||||
1
web/e2e/fixtures/mock-data/review-summary.json
Normal file
1
web/e2e/fixtures/mock-data/review-summary.json
Normal 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}}
|
||||
1
web/e2e/fixtures/mock-data/reviews.json
Normal file
1
web/e2e/fixtures/mock-data/reviews.json
Normal 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"]}}]
|
||||
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" });
|
||||
}
|
||||
271
web/e2e/helpers/api-mocker.ts
Normal file
271
web/e2e/helpers/api-mocker.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
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: 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
147
web/e2e/specs/auth.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
34
web/e2e/specs/chat.spec.ts
Normal file
34
web/e2e/specs/chat.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
33
web/e2e/specs/classification.spec.ts
Normal file
33
web/e2e/specs/classification.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
web/e2e/specs/config-editor.spec.ts
Normal file
44
web/e2e/specs/config-editor.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
97
web/e2e/specs/explore.spec.ts
Normal file
97
web/e2e/specs/explore.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
web/e2e/specs/export.spec.ts
Normal file
74
web/e2e/specs/export.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
web/e2e/specs/face-library.spec.ts
Normal file
32
web/e2e/specs/face-library.spec.ts
Normal 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
253
web/e2e/specs/live.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
75
web/e2e/specs/logs.spec.ts
Normal file
75
web/e2e/specs/logs.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
227
web/e2e/specs/navigation.spec.ts
Normal file
227
web/e2e/specs/navigation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
web/e2e/specs/replay.spec.ts
Normal file
23
web/e2e/specs/replay.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
200
web/e2e/specs/review.spec.ts
Normal file
200
web/e2e/specs/review.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
40
web/e2e/specs/settings/ui-settings.spec.ts
Normal file
40
web/e2e/specs/settings/ui-settings.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
web/e2e/specs/system.spec.ts
Normal file
90
web/e2e/specs/system.spec.ts
Normal 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
64
web/package-lock.json
generated
@ -89,6 +89,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@ -1485,6 +1486,22 @@
|
||||
"url": "https://opencollective.com/unts"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
||||
@ -11336,6 +11353,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
|
||||
@ -13,6 +13,10 @@
|
||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"e2e:build": "tsc && vite build --base=/",
|
||||
"e2e": "playwright test --config e2e/playwright.config.ts",
|
||||
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
|
||||
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed",
|
||||
"i18n:extract": "i18next-cli extract",
|
||||
"i18n:extract:ci": "i18next-cli extract --ci",
|
||||
"i18n:status": "i18next-cli status"
|
||||
@ -98,6 +102,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user