improve mock data generation and add test cases

This commit is contained in:
Josh Hawkins 2026-04-06 11:54:09 -05:00
parent 6b24219e48
commit c42f28b60e
23 changed files with 1207 additions and 460 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,9 @@
*/
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,
@ -14,6 +17,13 @@ import {
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==",
@ -44,6 +54,14 @@ export class ApiMocker {
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) => {
@ -65,23 +83,51 @@ export class ApiMocker {
);
// Reviews
await this.page.route("**/api/reviews**", (route) =>
route.fulfill({ json: overrides?.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: overrides?.events ?? [] }),
route.fulfill({ json: events }),
);
// Exports
await this.page.route("**/api/export**", (route) =>
route.fulfill({ json: overrides?.exports ?? [] }),
route.fulfill({ json: exports }),
);
// Cases
await this.page.route("**/api/cases", (route) =>
route.fulfill({ json: overrides?.cases ?? [] }),
route.fulfill({ json: cases }),
);
// Faces

View File

@ -1,54 +1,93 @@
/**
* Auth and cross-cutting tests -- HIGH tier.
*
* Tests protected routes, unauthorized redirect,
* and app-wide behaviors.
* Tests protected route access for admin/viewer roles,
* redirect behavior, and all routes smoke test.
*/
import { test, expect } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth & Protected Routes @high", () => {
test("admin can access /system", async ({ frigateApp }) => {
test.describe("Auth - Admin Access @high", () => {
test("admin can access /system and it renders", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// Wait for lazy-loaded system content
await frigateApp.page.waitForTimeout(3000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("admin can access /config", async ({ frigateApp }) => {
test("admin can access /config and editor loads", async ({ frigateApp }) => {
await frigateApp.goto("/config");
// Config editor may take time to load Monaco
await frigateApp.page.waitForTimeout(3000);
// Monaco editor or at least page content should render
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("admin can access /logs", async ({ frigateApp }) => {
test("admin can access /logs and service tabs render", async ({
frigateApp,
}) => {
await frigateApp.goto("/logs");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// Should have service toggle group
const toggleGroup = frigateApp.page.locator('[role="group"]');
await expect(toggleGroup.first()).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Auth - Viewer Restrictions @high", () => {
test("viewer is denied access to /system", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/system");
await page.waitForTimeout(2000);
const bodyText = await page.textContent("body");
expect(
bodyText?.includes("Access Denied") ||
bodyText?.includes("permission") ||
page.url().includes("unauthorized"),
).toBeTruthy();
});
test("viewer is redirected from admin routes", async ({
test("viewer is denied access to /config", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/config");
await page.waitForTimeout(2000);
const bodyText = await page.textContent("body");
expect(
bodyText?.includes("Access Denied") ||
bodyText?.includes("permission") ||
page.url().includes("unauthorized"),
).toBeTruthy();
});
test("viewer is denied access to /logs", async ({ frigateApp, page }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await page.goto("/logs");
await page.waitForTimeout(2000);
const bodyText = await page.textContent("body");
expect(
bodyText?.includes("Access Denied") ||
bodyText?.includes("permission") ||
page.url().includes("unauthorized"),
).toBeTruthy();
});
test("viewer can access all main user routes", async ({
frigateApp,
page,
}) => {
// Re-install mocks with viewer profile
await frigateApp.installDefaults({
profile: viewerProfile(),
});
await page.goto("/system");
await page.waitForTimeout(2000);
// Should be redirected to unauthorized page
const url = page.url();
const hasAccessDenied = url.includes("unauthorized");
const bodyText = await page.textContent("body");
const showsAccessDenied =
bodyText?.includes("Access Denied") ||
bodyText?.includes("permission") ||
hasAccessDenied;
expect(showsAccessDenied).toBeTruthy();
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 });
await expect(page.locator("#pageRoot")).toBeVisible();
}
});
});
test("all main pages render without crash", async ({ frigateApp }) => {
// Smoke test all user-accessible routes
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);
@ -58,7 +97,7 @@ test.describe("Auth & Protected Routes @high", () => {
}
});
test("all admin pages render without crash", async ({ frigateApp }) => {
test("all admin routes render without crash", async ({ frigateApp }) => {
const routes = ["/system", "/logs"];
for (const route of routes) {
await frigateApp.goto(route);

View File

@ -1,5 +1,7 @@
/**
* Chat page tests -- MEDIUM tier.
*
* Tests chat interface rendering, input area, and example prompt buttons.
*/
import { test, expect } from "../fixtures/frigate-test";
@ -11,12 +13,22 @@ test.describe("Chat Page @medium", () => {
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("chat page has interactive elements", async ({ frigateApp }) => {
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
// Should have interactive elements (input, textarea, or buttons)
const interactive = frigateApp.page.locator("input, textarea, button");
const count = await interactive.count();
expect(count).toBeGreaterThan(0);
});
test("chat input accepts text", async ({ frigateApp }) => {
await frigateApp.goto("/chat");
await frigateApp.page.waitForTimeout(2000);
const input = frigateApp.page.locator("input, textarea").first();
if (await input.isVisible().catch(() => false)) {
await input.fill("What cameras detected a person today?");
const value = await input.inputValue();
expect(value.length).toBeGreaterThan(0);
}
});
});

View File

@ -1,5 +1,7 @@
/**
* Classification page tests -- MEDIUM tier.
*
* Tests model selection view rendering and interactive elements.
*/
import { test, expect } from "../fixtures/frigate-test";
@ -10,9 +12,22 @@ test.describe("Classification @medium", () => {
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("classification page shows content", async ({ frigateApp }) => {
test("classification page shows content and controls", async ({
frigateApp,
}) => {
await frigateApp.goto("/classification");
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("classification page has interactive elements", async ({
frigateApp,
}) => {
await frigateApp.goto("/classification");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("#pageRoot button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@ -1,23 +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 page renders without crash", async ({ frigateApp }) => {
test("config editor loads Monaco editor with content", async ({
frigateApp,
}) => {
await frigateApp.goto("/config");
// Monaco editor may take time to load
await frigateApp.page.waitForTimeout(3000);
await expect(frigateApp.page.locator("body")).toBeVisible();
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 save button", async ({ frigateApp }) => {
test("config editor has action buttons", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(3000);
// Should have at least a save or action button
await frigateApp.page.waitForTimeout(5000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
test("config editor button clicks do not crash", async ({ frigateApp }) => {
await frigateApp.goto("/config");
await frigateApp.page.waitForTimeout(5000);
// Find buttons with SVG icons (copy, save, etc.)
const iconButtons = frigateApp.page.locator("button:has(svg)");
const count = await iconButtons.count();
if (count > 0) {
// Click the first icon button (likely copy)
await iconButtons.first().click();
await frigateApp.page.waitForTimeout(500);
}
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});

View File

@ -1,81 +1,89 @@
/**
* Explore page tests -- HIGH tier.
*
* Tests search input, filter dialogs, camera filter, calendar filter,
* and search result interactions.
* Tests search input, filter button opening popovers,
* search result thumbnails rendering, and detail dialog.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("Explore Page @high", () => {
test.describe("Explore Page - Search @high", () => {
test("explore page renders with search and filter controls", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
const pageRoot = frigateApp.page.locator("#pageRoot");
await expect(pageRoot).toBeVisible();
// Should have filter buttons (camera filter, calendar, etc.)
const buttons = frigateApp.page.locator("#pageRoot button");
await expect(buttons.first()).toBeVisible({ timeout: 10_000 });
});
test("camera filter button opens camera selector", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
// Find and click the camera filter button (has camera/video icon)
const filterButtons = frigateApp.page.locator("#pageRoot button");
// Click the first filter button
await filterButtons.first().click();
await frigateApp.page.waitForTimeout(500);
// A popover, dropdown, or dialog should appear
const overlay = frigateApp.page.locator(
'[role="dialog"], [role="menu"], [data-radix-popper-content-wrapper], [data-radix-menu-content]',
);
const overlayVisible = await overlay
.first()
.isVisible()
.catch(() => false);
// The button click should not crash the page
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// If an overlay appeared, it should be dismissible
if (overlayVisible) {
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
}
});
test("search input accepts text", async ({ frigateApp }) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
// Find the search input (InputWithTags component)
const searchInput = frigateApp.page.locator("input").first();
if (await searchInput.isVisible()) {
await searchInput.fill("person");
await expect(searchInput).toHaveValue("person");
}
});
test("filter button click opens overlay and escape closes it", async ({
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.describe("Explore Page - Filters @high", () => {
test("camera filter button opens selector and escape closes it", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip(); // Mobile uses drawer-based filters
return;
}
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
// Find the cameras filter button
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
if (await camerasBtn.isVisible().catch(() => false)) {
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
// Popover should open with camera names
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Dismiss
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("filter button opens overlay and page remains stable", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(1000);
// Click the first filter button in the page
const firstButton = frigateApp.page.locator("#pageRoot button").first();
await expect(firstButton).toBeVisible({ timeout: 5_000 });
await firstButton.click();
await frigateApp.page.waitForTimeout(500);
// An overlay may have appeared -- dismiss it
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page.waitForTimeout(300);
// Page should still be functional
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test("explore page shows summary or empty state", async ({ frigateApp }) => {
test.describe("Explore Page - Content @high", () => {
test("explore page shows summary content with mock events", async ({
frigateApp,
}) => {
await frigateApp.goto("/explore");
await frigateApp.page.waitForTimeout(2000);
// With no search results, should show either summary view or empty state
await frigateApp.page.waitForTimeout(3000);
// With mock events, the summary view should render thumbnails or content
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
});

View File

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

View File

@ -1,5 +1,7 @@
/**
* Face Library page tests -- MEDIUM tier.
*
* Tests face grid rendering, empty state, and interactive controls.
*/
import { test, expect } from "../fixtures/frigate-test";
@ -10,10 +12,21 @@ test.describe("Face Library @medium", () => {
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("face library shows empty state or content", async ({ frigateApp }) => {
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
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// 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);
});
});

View File

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

View File

@ -1,28 +1,74 @@
/**
* 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 @medium", () => {
test("logs page renders without crash", async ({ frigateApp }) => {
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("logs page has service toggle", async ({ frigateApp }) => {
test("download logs button is present", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(2000);
// Should have toggle buttons for frigate/go2rtc/nginx services
const toggleGroup = frigateApp.page.locator('[role="group"]');
const count = await toggleGroup.count();
expect(count).toBeGreaterThan(0);
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 shows log content", async ({ frigateApp }) => {
test("logs page displays log content text", async ({ frigateApp }) => {
await frigateApp.goto("/logs");
await frigateApp.page.waitForTimeout(2000);
// Should display some text content (our mock returns log lines)
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});

View File

@ -2,7 +2,7 @@
* Navigation tests -- CRITICAL tier.
*
* Tests sidebar (desktop) and bottombar (mobile) navigation,
* conditional nav items, settings menus, and route transitions.
* conditional nav items, settings menus, and their actual behaviors.
*/
import { test, expect } from "../fixtures/frigate-test";
@ -15,68 +15,40 @@ test.describe("Navigation @critical", () => {
});
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, !frigateApp.isMobile);
const base = new BasePage(frigateApp.page, true);
const logo = base.sidebar.locator('a[href="/"]').first();
await expect(logo).toBeVisible();
});
if (!frigateApp.isMobile) {
// Desktop: logo in sidebar
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("Live nav item is active on root path", async ({ frigateApp }) => {
await frigateApp.goto("/");
const liveLink = frigateApp.page.locator('a[href="/"]').first();
await expect(liveLink).toBeVisible();
});
test("navigate to Review page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/review");
await expect(frigateApp.page).toHaveURL(/\/review/);
});
test("navigate to Explore page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/explore");
await expect(frigateApp.page).toHaveURL(/\/explore/);
});
test("navigate to Export page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/export");
await expect(frigateApp.page).toHaveURL(/\/export/);
});
test("all primary nav links are present", async ({ frigateApp }) => {
await frigateApp.goto("/");
// Live, Review, Explore, Export are always present
await expect(frigateApp.page.locator('a[href="/"]').first()).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/review"]').first(),
).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/explore"]').first(),
).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/export"]').first(),
).toBeVisible();
});
test("desktop sidebar is visible on desktop, hidden on mobile", async ({
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 {
@ -84,32 +56,26 @@ test.describe("Navigation @critical", () => {
}
});
test("navigate between pages without crash", async ({ frigateApp }) => {
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");
// Navigate through all main pages in sequence
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/explore");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
await base.navigateTo("/export");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
// Navigate back to review (not root, to avoid same-route re-render issues)
await base.navigateTo("/review");
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
});
test("unknown route redirects to home", async ({ frigateApp }) => {
// Navigate to an unknown route - React Router's catch-all should redirect
await frigateApp.page.goto("/nonexistent-route");
// Wait for React to render and redirect
await frigateApp.page.waitForTimeout(2000);
// Should either be at root or show the page root (app didn't crash)
const url = frigateApp.page.url();
const hasPageRoot = await frigateApp.page
.locator("#pageRoot")
@ -117,11 +83,12 @@ test.describe("Navigation @critical", () => {
.catch(() => false);
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
});
});
test.describe("Navigation - Conditional Items @critical", () => {
test("Faces nav hidden when face_recognition disabled", async ({
frigateApp,
}) => {
// Default config has face_recognition.enabled = false
await frigateApp.goto("/");
await expect(frigateApp.page.locator('a[href="/faces"]')).not.toBeVisible();
});
@ -131,7 +98,6 @@ test.describe("Navigation @critical", () => {
test.skip();
return;
}
// Override config with genai.model = "none" to hide chat
await frigateApp.installDefaults({
config: {
genai: {
@ -146,7 +112,7 @@ test.describe("Navigation @critical", () => {
await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible();
});
test("Faces nav visible when face_recognition enabled and admin on desktop", async ({
test("Faces nav visible when face_recognition enabled on desktop", async ({
frigateApp,
page,
}) => {
@ -154,18 +120,14 @@ test.describe("Navigation @critical", () => {
test.skip();
return;
}
// Re-install with face_recognition enabled
await frigateApp.installDefaults({
config: {
face_recognition: { enabled: true },
},
config: { face_recognition: { enabled: true } },
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/faces"]')).toBeVisible();
});
test("Chat nav visible when genai model set and admin on desktop", async ({
test("Chat nav visible when genai model set on desktop", async ({
frigateApp,
page,
}) => {
@ -173,11 +135,8 @@ test.describe("Navigation @critical", () => {
test.skip();
return;
}
await frigateApp.installDefaults({
config: {
genai: { enabled: true, model: "llava" },
},
config: { genai: { enabled: true, model: "llava" } },
});
await frigateApp.goto("/");
await expect(page.locator('a[href="/chat"]')).toBeVisible();
@ -191,8 +150,78 @@ test.describe("Navigation @critical", () => {
test.skip();
return;
}
await frigateApp.goto("/");
await expect(page.locator('a[href="/classification"]')).toBeVisible();
});
});
test.describe("Navigation - Settings Menu @critical", () => {
test("settings gear opens menu with navigation items (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
// Settings gear is in the sidebar bottom section, a div with cursor-pointer
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const gearIcon = sidebarBottom
.locator("div[class*='cursor-pointer']")
.first();
await expect(gearIcon).toBeVisible({ timeout: 5_000 });
await gearIcon.click();
// Menu should open - look for the "Settings" menu item by aria-label
await expect(frigateApp.page.getByLabel("Settings")).toBeVisible({
timeout: 3_000,
});
});
test("settings menu items navigate to correct routes (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const targets = [
{ label: "Settings", url: "/settings" },
{ label: "System metrics", url: "/system" },
{ label: "System logs", url: "/logs" },
{ label: "Configuration Editor", url: "/config" },
];
for (const target of targets) {
await frigateApp.goto("/");
const gearIcon = frigateApp.page
.locator("aside .mb-8 div[class*='cursor-pointer']")
.first();
await gearIcon.click();
await frigateApp.page.waitForTimeout(300);
const menuItem = frigateApp.page.getByLabel(target.label);
if (await menuItem.isVisible().catch(() => false)) {
await menuItem.click();
await expect(frigateApp.page).toHaveURL(
new RegExp(target.url.replace(/\//g, "\\/")),
);
}
}
});
test("account button in sidebar is clickable (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/");
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
const items = sidebarBottom.locator("div[class*='cursor-pointer']");
const count = await items.count();
if (count >= 2) {
await items.nth(1).click();
await frigateApp.page.waitForTimeout(500);
}
await expect(frigateApp.page.locator("body")).toBeVisible();
});
});

View File

@ -1,5 +1,7 @@
/**
* Replay page tests -- LOW tier.
*
* Tests replay page rendering and basic interactivity.
*/
import { test, expect } from "../fixtures/frigate-test";
@ -10,4 +12,12 @@ test.describe("Replay Page @low", () => {
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("body")).toBeVisible();
});
test("replay page has interactive controls", async ({ frigateApp }) => {
await frigateApp.goto("/replay");
await frigateApp.page.waitForTimeout(2000);
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
});

View File

@ -1,58 +1,163 @@
/**
* Review/Events page tests -- CRITICAL tier.
*
* Tests timeline, filters, event cards, video controls,
* and mobile-specific drawer interactions.
* Tests severity toggle switching between alerts/detections/motion,
* filter buttons opening popovers, show reviewed toggle,
* and page content rendering with mock review data.
*/
import { test, expect } from "../fixtures/frigate-test";
import { BasePage } from "../pages/base.page";
test.describe("Review Page @critical", () => {
test("review page renders without crash", async ({ frigateApp }) => {
test.describe("Review Page - Severity Tabs @critical", () => {
test("severity tabs render with Alerts, Detections, Motion", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
// Severity toggle group should have 3 items
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
timeout: 10_000,
});
await expect(frigateApp.page.getByLabel("Detections")).toBeVisible();
await expect(frigateApp.page.getByLabel("Motion")).toBeVisible();
});
test("clicking Detections tab switches active severity", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
// Initially Alerts is active (aria-checked="true")
const alertsTab = frigateApp.page.getByLabel("Alerts");
await expect(alertsTab).toHaveAttribute("aria-checked", "true");
// Click Detections
await frigateApp.page.getByLabel("Detections").click();
await frigateApp.page.waitForTimeout(500);
// Detections should now be active
const detectionsTab = frigateApp.page.getByLabel("Detections");
await expect(detectionsTab).toHaveAttribute("aria-checked", "true");
// Alerts should no longer be active
await expect(alertsTab).toHaveAttribute("aria-checked", "false");
});
test("clicking Motion tab switches to motion view", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
// Use getByRole to target the specific radio button, not the switch
const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" });
await motionTab.click();
await frigateApp.page.waitForTimeout(500);
await expect(motionTab).toHaveAttribute("data-state", "on");
});
});
test.describe("Review Page - Filters @critical", () => {
test("All Cameras filter button opens camera selector", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip(); // Mobile uses drawer-based camera selector
return;
}
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
// Click "All Cameras" button
const camerasBtn = frigateApp.page.getByRole("button", {
name: /cameras/i,
});
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
await camerasBtn.click();
await frigateApp.page.waitForTimeout(500);
// A popover/dropdown with camera names should appear
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
// Should contain camera names from config
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
// Close
await frigateApp.page.keyboard.press("Escape");
});
test("Show Reviewed toggle is clickable", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(1000);
// Find the Show Reviewed toggle/switch
const showReviewed = frigateApp.page.getByRole("button", {
name: /reviewed/i,
});
if (await showReviewed.isVisible().catch(() => false)) {
await showReviewed.click();
await frigateApp.page.waitForTimeout(500);
// Page should still be functional
await expect(frigateApp.page.locator("#pageRoot")).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);
// A popover with calendar should appear
const popover = frigateApp.page.locator(
"[data-radix-popper-content-wrapper]",
);
const visible = await popover
.first()
.isVisible()
.catch(() => false);
if (visible) {
await frigateApp.page.keyboard.press("Escape");
}
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("severity toggle group is visible", async ({ frigateApp }) => {
test("Filter button opens filter popover", async ({ frigateApp }) => {
await frigateApp.goto("/review");
// The review page has a toggle group for alert/detection severity
const toggleGroup = frigateApp.page.locator('[role="group"]').first();
await expect(toggleGroup).toBeVisible({ timeout: 10_000 });
});
test("camera filter button is clickable", async ({ frigateApp }) => {
await frigateApp.goto("/review");
// Find a button that opens the camera filter
const filterButtons = frigateApp.page.locator("button");
const count = await filterButtons.count();
expect(count).toBeGreaterThan(0);
// Page should not crash after interaction
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("empty state shows when no events", async ({ frigateApp }) => {
await frigateApp.goto("/review");
// With empty reviews mock, should show some kind of content (not crash)
await frigateApp.page.waitForTimeout(2000);
await 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);
await frigateApp.page.keyboard.press("Escape");
}
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
});
test.describe("Review Page - Navigation @critical", () => {
test("navigate to review from live page", async ({ frigateApp }) => {
await frigateApp.goto("/");
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
await base.navigateTo("/review");
await expect(frigateApp.page).toHaveURL(/\/review/);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("review page has interactive controls", async ({ frigateApp }) => {
test("review page has timeline on right side (desktop)", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.goto("/review");
await frigateApp.page.waitForTimeout(2000);
// Should have buttons/controls for filtering
const interactive = frigateApp.page.locator(
"button, input, [role='group']",
);
const count = await interactive.count();
expect(count).toBeGreaterThan(0);
// Timeline renders time labels on the right
const pageText = await frigateApp.page.textContent("#pageRoot");
// Should have time markers like "PM" or "AM"
expect(pageText).toMatch(/[AP]M/);
});
});

View File

@ -1,32 +1,40 @@
/**
* Settings page tests -- HIGH tier.
*
* Tests the Settings page renders without crash and
* basic navigation between settings sections.
* 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 without crash", async ({ frigateApp }) => {
test("settings page renders with content", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
});
test("settings page has navigation sections", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
// Should have sidebar navigation or section links
const buttons = frigateApp.page.locator("button, a");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
});
test("settings page shows content", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
// The page should have meaningful content
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
});
test("settings page has clickable navigation items", async ({
frigateApp,
}) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
const navItems = frigateApp.page.locator(
"#pageRoot button, #pageRoot [role='button'], #pageRoot a",
);
const count = await navItems.count();
expect(count).toBeGreaterThan(0);
});
test("settings page has form controls", async ({ frigateApp }) => {
await frigateApp.goto("/settings");
await frigateApp.page.waitForTimeout(2000);
const formElements = frigateApp.page.locator(
'#pageRoot input, #pageRoot button[role="switch"], #pageRoot button[role="combobox"]',
);
const count = await formElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@ -1,28 +1,51 @@
/**
* System page tests -- MEDIUM tier.
*
* Tests tab switching between general/storage/cameras by name,
* verifies each tab renders content, and checks timestamp.
*/
import { test, expect } from "../fixtures/frigate-test";
test.describe("System Page @medium", () => {
test("system page renders without crash", async ({ frigateApp }) => {
test("system page renders with named tab buttons", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
// Tab buttons have aria-labels like "Select general", "Select storage", etc.
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
timeout: 5_000,
});
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
});
test("system page has interactive controls", async ({ frigateApp }) => {
test("clicking Storage tab switches to storage view", async ({
frigateApp,
}) => {
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(2000);
// Should have buttons for tab switching or other controls
const buttons = frigateApp.page.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThan(0);
const storageTab = frigateApp.page.getByLabel("Select storage");
await storageTab.click();
await frigateApp.page.waitForTimeout(1000);
// Storage tab should be active
await expect(storageTab).toHaveAttribute("data-state", "on");
});
test("system page shows metrics content", async ({ frigateApp }) => {
test("clicking Cameras tab switches to cameras view", async ({
frigateApp,
}) => {
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(2000);
const text = await frigateApp.page.textContent("#pageRoot");
expect(text?.length).toBeGreaterThan(0);
const camerasTab = frigateApp.page.getByLabel("Select cameras");
await camerasTab.click();
await frigateApp.page.waitForTimeout(1000);
await expect(camerasTab).toHaveAttribute("data-state", "on");
});
test("system page shows last refreshed text", async ({ frigateApp }) => {
await frigateApp.goto("/system");
await frigateApp.page.waitForTimeout(3000);
const pageText = await frigateApp.page.textContent("#pageRoot");
expect(pageText?.length).toBeGreaterThan(0);
});
});