mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-08 14:35:26 +03:00
Compare commits
10 Commits
fe75165962
...
67a408ebba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a408ebba | ||
|
|
c244e6582a | ||
|
|
fff3594553 | ||
|
|
25bfb2c481 | ||
|
|
b7261c8e70 | ||
|
|
ad9092d0da | ||
|
|
20705a3e97 | ||
|
|
f4ac063b37 | ||
|
|
2dcaeb6809 | ||
|
|
962d36323b |
@ -87,43 +87,43 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
# intel packages use zst compression so we need to update dpkg
|
||||
apt-get install -y dpkg
|
||||
|
||||
# use intel apt intel packages
|
||||
# use intel apt repo for libmfx1 (legacy QSV, pre-Gen12)
|
||||
wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||
apt-get -qq update
|
||||
|
||||
# intel-media-va-driver-non-free is built from source in the
|
||||
# intel-media-driver Dockerfile stage for Battlemage (Xe2) support
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
libmfx1 libmfxgen1 libvpl2
|
||||
libmfx1
|
||||
rm -f /usr/share/keyrings/intel-graphics.gpg
|
||||
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||
|
||||
# upgrade libva2, oneVPL runtime, and libvpl2 from trixie for Battlemage support
|
||||
echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install -y -t trixie libva2 libva-drm2 libzstd1
|
||||
apt-get -qq install -y -t trixie libmfx-gen1.2 libvpl2
|
||||
rm -f /etc/apt/sources.list.d/trixie.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install -y ocl-icd-libopencl1
|
||||
|
||||
# install libtbb12 for NPU support
|
||||
apt-get -qq install -y libtbb12
|
||||
|
||||
rm -f /usr/share/keyrings/intel-graphics.gpg
|
||||
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||
|
||||
# install legacy and standard intel icd and level-zero-gpu
|
||||
# install legacy and standard intel compute packages
|
||||
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
|
||||
# newer intel packages (gmmlib 22.9+, igc 2.32+) require libstdc++ >= 13.1 and libzstd >= 1.5.5
|
||||
echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install -y -t trixie libstdc++6 libzstd1
|
||||
rm -f /etc/apt/sources.list.d/trixie.list
|
||||
apt-get -qq update
|
||||
|
||||
# needed core package
|
||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
||||
dpkg -i libigdgmm12_22.9.0_amd64.deb
|
||||
rm libigdgmm12_22.9.0_amd64.deb
|
||||
|
||||
# legacy packages
|
||||
# legacy compute-runtime packages
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
|
||||
# standard packages
|
||||
# standard compute-runtime packages
|
||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
|
||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
|
||||
@ -137,6 +137,10 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
dpkg -i *.deb
|
||||
rm *.deb
|
||||
apt-get -qq install -f -y
|
||||
|
||||
# Battlemage uses the xe kernel driver, but the VA-API driver is still iHD.
|
||||
# The oneVPL runtime may look for a driver named after the kernel module.
|
||||
ln -sf /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so /usr/lib/x86_64-linux-gnu/dri/xe_drv_video.so
|
||||
fi
|
||||
|
||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
|
||||
@ -11,7 +11,7 @@ joserfc == 1.2.*
|
||||
cryptography == 44.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.20
|
||||
python-multipart == 0.0.26
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
@ -42,7 +42,7 @@ opencv-python-headless == 4.11.0.*
|
||||
opencv-contrib-python == 4.11.0.*
|
||||
scipy == 1.16.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2025.3.*
|
||||
openvino == 2025.4.*
|
||||
onnxruntime == 1.22.*
|
||||
# Embeddings
|
||||
transformers == 4.45.*
|
||||
|
||||
@ -111,26 +111,16 @@ TCP ensures that all data packets arrive in the correct order. This is crucial f
|
||||
|
||||
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
|
||||
|
||||
### Frigate hangs on startup with a "probing detect stream" message in the logs
|
||||
### Frigate is slow to start up with a "probing detect stream" message in the logs
|
||||
|
||||
On startup, Frigate probes each camera's detect stream with OpenCV to auto-detect its resolution. OpenCV's FFmpeg backend may attempt RTSP over UDP during this probe regardless of the `-rtsp_transport tcp` in your `input_args` or preset. For cameras that do not respond to UDP (common on some Reolink models and others behind firewalls that block UDP), the probe can hang indefinitely and block Frigate from finishing startup, or it can return zeroed-out dimensions that show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
||||
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
||||
|
||||
There are two ways to avoid this:
|
||||
To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config:
|
||||
|
||||
1. Set `detect.width` and `detect.height` explicitly in your camera config. When both are set, Frigate skips the auto-detect probe entirely:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
my_camera:
|
||||
detect:
|
||||
width: 1280
|
||||
height: 720
|
||||
```
|
||||
|
||||
2. Force OpenCV's FFmpeg backend to use TCP for RTSP by setting the environment variable on your Frigate container:
|
||||
|
||||
```
|
||||
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp
|
||||
```
|
||||
|
||||
This is a process-wide setting and applies to all cameras. If you have any cameras that require `preset-rtsp-udp`, use option 1 instead.
|
||||
```yaml
|
||||
cameras:
|
||||
my_camera:
|
||||
detect:
|
||||
width: 1280
|
||||
height: 720
|
||||
```
|
||||
|
||||
6
docs/package-lock.json
generated
6
docs/package-lock.json
generated
@ -10897,9 +10897,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/range-parser": {
|
||||
|
||||
@ -310,6 +310,10 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self._handle_custom_classification_update(topic, payload)
|
||||
return
|
||||
|
||||
if topic == "config/genai":
|
||||
self.config.genai = payload
|
||||
self.genai_manager.update_config(self.config)
|
||||
|
||||
# Broadcast to all processors — each decides if the topic is relevant
|
||||
for processor in self.realtime_processors:
|
||||
processor.update_config(topic, payload)
|
||||
|
||||
@ -113,6 +113,15 @@ class OllamaClient(GenAIClient):
|
||||
schema = response_format.get("json_schema", {}).get("schema")
|
||||
if schema:
|
||||
ollama_options["format"] = self._clean_schema_for_ollama(schema)
|
||||
logger.debug(
|
||||
"Ollama generate request: model=%s, prompt_len=%s, image_count=%s, "
|
||||
"has_format=%s, options=%s",
|
||||
self.genai_config.model,
|
||||
len(prompt),
|
||||
len(images) if images else 0,
|
||||
"format" in ollama_options,
|
||||
{k: v for k, v in ollama_options.items() if k != "format"},
|
||||
)
|
||||
result = self.provider.generate(
|
||||
self.genai_config.model,
|
||||
prompt,
|
||||
@ -120,9 +129,24 @@ class OllamaClient(GenAIClient):
|
||||
**ollama_options,
|
||||
)
|
||||
logger.debug(
|
||||
f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}"
|
||||
"Ollama generate response: done=%s, done_reason=%s, eval_count=%s, "
|
||||
"prompt_eval_count=%s, response_len=%s",
|
||||
result.get("done"),
|
||||
result.get("done_reason"),
|
||||
result.get("eval_count"),
|
||||
result.get("prompt_eval_count"),
|
||||
len(result.get("response", "") or ""),
|
||||
)
|
||||
return str(result["response"]).strip()
|
||||
response_text = str(result["response"]).strip()
|
||||
if not response_text:
|
||||
logger.warning(
|
||||
"Ollama returned a blank response for model %s (done_reason=%s, "
|
||||
"eval_count=%s). Check model output, ensure thinking is disabled.",
|
||||
self.genai_config.model,
|
||||
result.get("done_reason"),
|
||||
result.get("eval_count"),
|
||||
)
|
||||
return response_text
|
||||
except (
|
||||
TimeoutException,
|
||||
ResponseError,
|
||||
|
||||
@ -80,7 +80,23 @@ class OpenAIClient(GenAIClient):
|
||||
and hasattr(result, "choices")
|
||||
and len(result.choices) > 0
|
||||
):
|
||||
return str(result.choices[0].message.content.strip())
|
||||
message = result.choices[0].message
|
||||
content = message.content
|
||||
|
||||
if not content:
|
||||
# When reasoning is enabled for some OpenAI backends the actual response
|
||||
# is incorrectly placed in reasoning_content instead of content.
|
||||
# This is buggy/incorrect behavior — reasoning should not be
|
||||
# enabled for these models.
|
||||
reasoning_content = getattr(message, "reasoning_content", None)
|
||||
if reasoning_content:
|
||||
logger.warning(
|
||||
"Response content was empty but reasoning_content was provided; "
|
||||
"reasoning appears to be enabled and should be disabled for this model."
|
||||
)
|
||||
content = reasoning_content
|
||||
|
||||
return str(content.strip()) if content else None
|
||||
return None
|
||||
except (TimeoutException, Exception) as e:
|
||||
logger.warning("OpenAI returned an error: %s", str(e))
|
||||
|
||||
@ -807,10 +807,15 @@ async def get_video_properties(
|
||||
) -> dict[str, Any]:
|
||||
async def probe_with_ffprobe(
|
||||
url: str,
|
||||
rtsp_transport: Optional[str] = None,
|
||||
) -> tuple[bool, int, int, Optional[str], float]:
|
||||
"""Fallback using ffprobe: returns (valid, width, height, codec, duration)."""
|
||||
cmd = [
|
||||
ffmpeg.ffprobe_path,
|
||||
cmd = [ffmpeg.ffprobe_path]
|
||||
if rtsp_transport:
|
||||
cmd += ["-rtsp_transport", rtsp_transport]
|
||||
cmd += [
|
||||
"-rw_timeout",
|
||||
"5000000",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
@ -872,12 +877,26 @@ async def get_video_properties(
|
||||
cap.release()
|
||||
return valid, width, height, fourcc, duration
|
||||
|
||||
# try cv2 first
|
||||
has_video, width, height, fourcc, duration = probe_with_cv2(url)
|
||||
is_rtsp = url.startswith("rtsp://")
|
||||
|
||||
# fallback to ffprobe if needed
|
||||
if not has_video or (get_duration and duration < 0):
|
||||
if is_rtsp:
|
||||
# skip cv2 for RTSP: its FFmpeg backend has a hardcoded ~30s internal
|
||||
# timeout that cannot be shortened per-call, and ffprobe bounded by
|
||||
# -rw_timeout handles RTSP probing reliably
|
||||
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
||||
else:
|
||||
# try cv2 first for local files, HTTP, RTMP
|
||||
has_video, width, height, fourcc, duration = probe_with_cv2(url)
|
||||
|
||||
# fallback to ffprobe if needed
|
||||
if not has_video or (get_duration and duration < 0):
|
||||
has_video, width, height, fourcc, duration = await probe_with_ffprobe(url)
|
||||
|
||||
# last resort for RTSP: try TCP transport, since default UDP may be blocked
|
||||
if (not has_video or (get_duration and duration < 0)) and is_rtsp:
|
||||
has_video, width, height, fourcc, duration = await probe_with_ffprobe(
|
||||
url, rtsp_transport="tcp"
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {"has_valid_video": has_video}
|
||||
if has_video:
|
||||
|
||||
54
web/e2e/fixtures/mock-data/debug-replay.ts
Normal file
54
web/e2e/fixtures/mock-data/debug-replay.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Debug replay status factory.
|
||||
*
|
||||
* The Replay page polls /api/debug_replay/status every 1s via SWR.
|
||||
* The no-session state shows an empty state; the active state
|
||||
* renders the live camera image + debug toggles + objects/messages
|
||||
* tabs. Used by replay.spec.ts.
|
||||
*/
|
||||
|
||||
export type DebugReplayStatus = {
|
||||
active: boolean;
|
||||
replay_camera: string | null;
|
||||
source_camera: string | null;
|
||||
start_time: number | null;
|
||||
end_time: number | null;
|
||||
live_ready: boolean;
|
||||
};
|
||||
|
||||
export function noSessionStatus(): DebugReplayStatus {
|
||||
return {
|
||||
active: false,
|
||||
replay_camera: null,
|
||||
source_camera: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
live_ready: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function activeSessionStatus(
|
||||
opts: {
|
||||
camera?: string;
|
||||
sourceCamera?: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
liveReady?: boolean;
|
||||
} = {},
|
||||
): DebugReplayStatus {
|
||||
const {
|
||||
camera = "front_door",
|
||||
sourceCamera = "front_door",
|
||||
startTime = Date.now() / 1000 - 3600,
|
||||
endTime = Date.now() / 1000 - 1800,
|
||||
liveReady = true,
|
||||
} = opts;
|
||||
return {
|
||||
active: true,
|
||||
replay_camera: camera,
|
||||
source_camera: sourceCamera,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
live_ready: liveReady,
|
||||
};
|
||||
}
|
||||
45
web/e2e/fixtures/mock-data/faces.ts
Normal file
45
web/e2e/fixtures/mock-data/faces.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Face library factories.
|
||||
*
|
||||
* The /api/faces endpoint returns a record keyed by collection name
|
||||
* with the list of face image filenames. Grouped training attempts
|
||||
* live under the "train" key with filenames of the form
|
||||
* `${event_id}-${timestamp}-${label}-${score}.webp`.
|
||||
*
|
||||
* Used by face-library.spec.ts and chat.spec.ts (attachment chip).
|
||||
*/
|
||||
|
||||
export type FacesMock = Record<string, string[]>;
|
||||
|
||||
export function basicFacesMock(): FacesMock {
|
||||
return {
|
||||
alice: ["alice-1.webp", "alice-2.webp"],
|
||||
bob: ["bob-1.webp"],
|
||||
charlie: ["charlie-1.webp"],
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyFacesMock(): FacesMock {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a grouped recent-recognition training attempt to an existing
|
||||
* faces mock. The grouping key on the backend is the event id — so
|
||||
* images with the same event-id prefix render as one dialog-able card.
|
||||
*/
|
||||
export function withGroupedTrainingAttempt(
|
||||
base: FacesMock,
|
||||
opts: {
|
||||
eventId: string;
|
||||
attempts: Array<{ timestamp: number; label: string; score: number }>;
|
||||
},
|
||||
): FacesMock {
|
||||
const trainImages = opts.attempts.map(
|
||||
(a) => `${opts.eventId}-${a.timestamp}-${a.label}-${a.score}.webp`,
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
train: [...(base.train ?? []), ...trainImages],
|
||||
};
|
||||
}
|
||||
@ -113,11 +113,12 @@ export class ApiMocker {
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
|
||||
// Sub-labels and attributes (for explore filters)
|
||||
await this.page.route("**/api/sub_labels", (route) =>
|
||||
// Sub-labels and attributes (for explore filters).
|
||||
// Use trailing ** so query-string variants (e.g. ?split_joined=1) match.
|
||||
await this.page.route("**/api/sub_labels**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await this.page.route("**/api/labels", (route) =>
|
||||
await this.page.route("**/api/labels**", (route) =>
|
||||
route.fulfill({ json: ["person", "car"] }),
|
||||
);
|
||||
await this.page.route("**/api/*/attributes", (route) =>
|
||||
|
||||
25
web/e2e/helpers/clipboard.ts
Normal file
25
web/e2e/helpers/clipboard.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Clipboard read helper for e2e tests.
|
||||
*
|
||||
* Clipboard API requires a browser permission in headless mode.
|
||||
* grantClipboardPermissions() must be called before any readClipboard()
|
||||
* attempt. Used by logs.spec.ts (Copy button) and config-editor.spec.ts
|
||||
* (Copy button).
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Grant clipboard-read + clipboard-write permissions on the context.
|
||||
* Call in beforeEach or at the top of a test before the Copy action.
|
||||
*/
|
||||
export async function grantClipboardPermissions(
|
||||
context: BrowserContext,
|
||||
): Promise<void> {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
}
|
||||
|
||||
/** Read the current clipboard contents via the page's navigator.clipboard. */
|
||||
export async function readClipboard(page: Page): Promise<string> {
|
||||
return page.evaluate(async () => await navigator.clipboard.readText());
|
||||
}
|
||||
58
web/e2e/helpers/monaco.ts
Normal file
58
web/e2e/helpers/monaco.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Monaco editor DOM helpers for e2e tests.
|
||||
*
|
||||
* Monaco is imported as a module-local object in the app and is NOT
|
||||
* exposed on window; we drive + read through the rendered DOM and
|
||||
* keyboard instead. Used by config-editor.spec.ts only.
|
||||
*/
|
||||
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Returns the current visible text of the first Monaco editor on the
|
||||
* page. Monaco virtualizes long files — this reads only the rendered
|
||||
* lines. For short configs (our mocks) that's the full content.
|
||||
*/
|
||||
export async function getMonacoVisibleText(page: Page): Promise<string> {
|
||||
return page.locator(".monaco-editor .view-lines").first().innerText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the editor and replace its full content with `value` via
|
||||
* keyboard. Uses Ctrl+A (Cmd+A on macOS Playwright is equivalent)
|
||||
* + Delete + type. Works cross-platform because Playwright normalizes.
|
||||
*/
|
||||
export async function replaceMonacoValue(
|
||||
page: Page,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const editor = page.locator(".monaco-editor").first();
|
||||
await editor.click();
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
await page.keyboard.press("Delete");
|
||||
// Use `type` with zero delay — Monaco handles each key.
|
||||
await page.keyboard.type(value, { delay: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the editor shows at least one error-severity
|
||||
* marker. Monaco renders error underlines as `.squiggly-error` in
|
||||
* the `.view-overlays` layer.
|
||||
*/
|
||||
export async function hasErrorMarkers(page: Page): Promise<boolean> {
|
||||
const count = await page.locator(".monaco-editor .squiggly-error").count();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until an error marker appears. Monaco schedules marker updates
|
||||
* asynchronously after content changes (debounce + schema validation).
|
||||
*/
|
||||
export async function waitForErrorMarker(
|
||||
page: Page,
|
||||
timeoutMs: number = 10_000,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => hasErrorMarkers(page), { timeout: timeoutMs })
|
||||
.toBe(true);
|
||||
}
|
||||
65
web/e2e/helpers/ws-frames.ts
Normal file
65
web/e2e/helpers/ws-frames.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* WebSocket frame capture helper.
|
||||
*
|
||||
* The ws-mocker intercepts the /ws route, so Playwright's page-level
|
||||
* `websocket` event never fires. This helper patches client-side
|
||||
* WebSocket.prototype.send before any app code runs and mirrors every
|
||||
* sent frame into a window-level array the test can read back.
|
||||
*
|
||||
* Used by live.spec.ts (feature toggles, PTZ preset commands) and
|
||||
* config-editor.spec.ts (restart command via useRestart).
|
||||
*/
|
||||
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export type CapturedFrame = string;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__sentWsFrames: CapturedFrame[];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch WebSocket.prototype.send to capture every outbound frame into
|
||||
* window.__sentWsFrames. Must be called BEFORE page.goto().
|
||||
*/
|
||||
export async function installWsFrameCapture(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.__sentWsFrames = [];
|
||||
const origSend = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function (data) {
|
||||
try {
|
||||
window.__sentWsFrames.push(
|
||||
typeof data === "string" ? data : "(binary)",
|
||||
);
|
||||
} catch {
|
||||
// ignore — best-effort tracing
|
||||
}
|
||||
return origSend.call(this, data);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Read all captured frames at call time. */
|
||||
export async function readWsFrames(page: Page): Promise<CapturedFrame[]> {
|
||||
return page.evaluate(() => window.__sentWsFrames ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until at least one captured frame matches the predicate.
|
||||
* Throws via expect if the frame never arrives within timeout.
|
||||
*/
|
||||
export async function waitForWsFrame(
|
||||
page: Page,
|
||||
matcher: (frame: CapturedFrame) => boolean,
|
||||
opts: { timeout?: number; message?: string } = {},
|
||||
): Promise<void> {
|
||||
const { timeout = 2_000, message } = opts;
|
||||
await expect
|
||||
.poll(async () => (await readWsFrames(page)).some(matcher), {
|
||||
timeout,
|
||||
message,
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
@ -79,7 +79,20 @@ export class WsMocker {
|
||||
this.send("model_state", JSON.stringify({}));
|
||||
}
|
||||
if (data.topic === "embeddingsReindexProgress") {
|
||||
this.send("embeddings_reindex_progress", JSON.stringify(null));
|
||||
// Send a completed reindex state so Explore renders when
|
||||
// semantic_search.enabled is true. A null payload leaves the page
|
||||
// in a permanent loading spinner because !reindexState is truthy.
|
||||
this.send(
|
||||
"embeddings_reindex_progress",
|
||||
JSON.stringify({
|
||||
status: "completed",
|
||||
processed_objects: 0,
|
||||
total_objects: 0,
|
||||
thumbnails: 0,
|
||||
descriptions: 0,
|
||||
time_remaining: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (data.topic === "birdseyeLayout") {
|
||||
this.send("birdseye_layout", JSON.stringify(null));
|
||||
|
||||
55
web/e2e/pages/live.page.ts
Normal file
55
web/e2e/pages/live.page.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Live dashboard + single-camera page object.
|
||||
*
|
||||
* Encapsulates selectors and viewport-conditional openers for the
|
||||
* Live route. Does NOT own assertions — specs call expect on the
|
||||
* locators returned from these getters.
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
|
||||
export class LivePage extends BasePage {
|
||||
constructor(page: Page, isDesktop: boolean) {
|
||||
super(page, isDesktop);
|
||||
}
|
||||
|
||||
/** The camera card wrapper on the dashboard, keyed by camera name. */
|
||||
cameraCard(name: string): Locator {
|
||||
return this.page.locator(`[data-camera='${name}']`);
|
||||
}
|
||||
|
||||
/** Back button on the single-camera view header (desktop text). */
|
||||
get backButton(): Locator {
|
||||
return this.page.getByText("Back", { exact: true });
|
||||
}
|
||||
|
||||
/** History button on the single-camera view header (desktop text). */
|
||||
get historyButton(): Locator {
|
||||
return this.page.getByText("History", { exact: true });
|
||||
}
|
||||
|
||||
/** All CameraFeatureToggle elements (active + inactive). */
|
||||
get featureToggles(): Locator {
|
||||
// Use div selector to exclude NavItem anchor elements that share the same classes.
|
||||
return this.page.locator(
|
||||
"div.flex.flex-col.items-center.justify-center.bg-selected, div.flex.flex-col.items-center.justify-center.bg-secondary",
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the active (bg-selected) feature toggles. */
|
||||
get activeFeatureToggles(): Locator {
|
||||
// Use div selector to exclude NavItem anchor elements that share the same classes.
|
||||
return this.page.locator(
|
||||
"div.flex.flex-col.items-center.justify-center.bg-selected",
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the right-click context menu on a camera card (desktop only). */
|
||||
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
||||
await this.cameraCard(cameraName).first().click({ button: "right" });
|
||||
return this.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
}
|
||||
}
|
||||
52
web/e2e/pages/review.page.ts
Normal file
52
web/e2e/pages/review.page.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Review/events page object.
|
||||
*
|
||||
* Encapsulates severity tab, filter bar, calendar, and mobile filter
|
||||
* drawer selectors. Does NOT own assertions.
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
|
||||
export class ReviewPage extends BasePage {
|
||||
constructor(page: Page, isDesktop: boolean) {
|
||||
super(page, isDesktop);
|
||||
}
|
||||
|
||||
get alertsTab(): Locator {
|
||||
return this.page.getByLabel("Alerts");
|
||||
}
|
||||
|
||||
get detectionsTab(): Locator {
|
||||
return this.page.getByLabel("Detections");
|
||||
}
|
||||
|
||||
get motionTab(): Locator {
|
||||
return this.page.getByRole("radio", { name: "Motion" });
|
||||
}
|
||||
|
||||
get camerasFilterTrigger(): Locator {
|
||||
return this.page.getByRole("button", { name: /cameras/i }).first();
|
||||
}
|
||||
|
||||
get calendarTrigger(): Locator {
|
||||
return this.page.getByRole("button", { name: /24 hours|calendar|date/i });
|
||||
}
|
||||
|
||||
get showReviewedToggle(): Locator {
|
||||
return this.page.getByRole("button", { name: /reviewed/i });
|
||||
}
|
||||
|
||||
get reviewItems(): Locator {
|
||||
return this.page.locator(".review-item");
|
||||
}
|
||||
|
||||
/** The filter popover content (desktop) or drawer (mobile). */
|
||||
get filterOverlay(): Locator {
|
||||
return this.page
|
||||
.locator(
|
||||
'[data-radix-popper-content-wrapper], [role="dialog"], [data-vaul-drawer]',
|
||||
)
|
||||
.first();
|
||||
}
|
||||
}
|
||||
@ -14,10 +14,6 @@
|
||||
*
|
||||
* @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must
|
||||
* contain at least one test title or describe with the substring "@mobile".
|
||||
*
|
||||
* Specs in PENDING_REWRITE are exempt from all rules until they are
|
||||
* rewritten with proper assertions and mobile coverage. Remove each
|
||||
* entry when its spec is updated.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
@ -28,24 +24,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SPECS_DIR = resolve(__dirname, "..", "specs");
|
||||
const META_PREFIX = resolve(SPECS_DIR, "_meta");
|
||||
|
||||
// Specs exempt from lint rules until they are rewritten with proper
|
||||
// assertions and mobile coverage. Remove each entry when its spec is updated.
|
||||
const PENDING_REWRITE = new Set([
|
||||
"auth.spec.ts",
|
||||
"chat.spec.ts",
|
||||
"classification.spec.ts",
|
||||
"config-editor.spec.ts",
|
||||
"explore.spec.ts",
|
||||
"export.spec.ts",
|
||||
"face-library.spec.ts",
|
||||
"live.spec.ts",
|
||||
"logs.spec.ts",
|
||||
"navigation.spec.ts",
|
||||
"replay.spec.ts",
|
||||
"review.spec.ts",
|
||||
"system.spec.ts",
|
||||
]);
|
||||
|
||||
const BANNED_PATTERNS = [
|
||||
{
|
||||
name: "page.waitForTimeout",
|
||||
@ -62,14 +40,12 @@ const BANNED_PATTERNS = [
|
||||
{
|
||||
name: "conditional count() assertion",
|
||||
regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/,
|
||||
advice:
|
||||
"Assertions must be unconditional. Use expect(...).toHaveCount(n).",
|
||||
advice: "Assertions must be unconditional. Use expect(...).toHaveCount(n).",
|
||||
},
|
||||
{
|
||||
name: "vacuous textContent length assertion",
|
||||
regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/,
|
||||
advice:
|
||||
"Assert specific content, not that some text exists.",
|
||||
advice: "Assert specific content, not that some text exists.",
|
||||
},
|
||||
];
|
||||
|
||||
@ -89,8 +65,6 @@ function walk(dir) {
|
||||
}
|
||||
|
||||
function lintFile(file) {
|
||||
const basename = file.split("/").pop();
|
||||
if (PENDING_REWRITE.has(basename)) return [];
|
||||
if (file.includes("/specs/settings/")) return [];
|
||||
|
||||
const errors = [];
|
||||
|
||||
@ -1,147 +1,110 @@
|
||||
/**
|
||||
* Auth and cross-cutting tests -- HIGH tier.
|
||||
* Auth and role tests -- HIGH tier.
|
||||
*
|
||||
* Tests protected route access for admin/viewer roles,
|
||||
* access denied page rendering, viewer nav restrictions,
|
||||
* and all routes smoke test.
|
||||
* Admin access to /system, /config, /logs; viewer access denied
|
||||
* markers (via i18n heading, not a data-testid we don't own);
|
||||
* viewer nav restrictions; all-routes smoke.
|
||||
*/
|
||||
|
||||
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,
|
||||
}) => {
|
||||
test.describe("Auth — admin access @high", () => {
|
||||
test("admin /system renders general tab", 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,
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("admin can access /config and Monaco editor loads", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test("admin /config renders Monaco editor", 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 });
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.locator(".monaco-editor, [data-keybinding-context]")
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("admin can access /logs and sees service tabs", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test("admin /logs renders frigate tab", 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.describe("Auth — viewer restrictions @high", () => {
|
||||
for (const path of ["/system", "/config", "/logs"]) {
|
||||
test(`viewer on ${path} sees AccessDenied`, async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||
await frigateApp.page.goto(path);
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /access denied/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
}
|
||||
|
||||
test("viewer sees cameras on /", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||
await frigateApp.page.goto("/");
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-camera='front_door']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("viewer sees Access Denied on /config", async ({ frigateApp, page }) => {
|
||||
test("viewer sees severity tabs on /review", async ({ frigateApp }) => {
|
||||
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({
|
||||
await frigateApp.page.goto("/review");
|
||||
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("viewer can access Review page and sees severity tabs", async ({
|
||||
test("viewer can access all non-admin routes without AccessDenied", 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 });
|
||||
await frigateApp.page.goto(route);
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /access denied/i,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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) {
|
||||
test.describe("Auth — viewer nav restrictions (desktop) @high", () => {
|
||||
test.skip(({ frigateApp }) => frigateApp.isMobile, "Sidebar only on desktop");
|
||||
|
||||
test("viewer sidebar hides admin routes", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||
await frigateApp.page.goto("/");
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
for (const href of ["/system", "/config", "/logs"]) {
|
||||
await expect(
|
||||
frigateApp.page.locator(`aside a[href='${href}']`),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Auth — all routes smoke @high @mobile", () => {
|
||||
test("every common route renders #pageRoot", async ({ frigateApp }) => {
|
||||
for (const route of ["/", "/review", "/explore", "/export", "/settings"]) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,34 +1,311 @@
|
||||
/**
|
||||
* Chat page tests -- MEDIUM tier.
|
||||
*
|
||||
* Tests chat interface rendering, input area, and example prompt buttons.
|
||||
* Starting state, NDJSON streaming contract (not SSE), assistant
|
||||
* bubble grows as chunks arrive, error path, and mobile viewport.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, type FrigateApp } 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();
|
||||
});
|
||||
/**
|
||||
* Install a window.fetch override on the page so that POSTs to
|
||||
* chat/completion resolve with a real ReadableStream that emits the
|
||||
* given chunks over time. This is the only way to validate
|
||||
* chunk-by-chunk rendering through Playwright — page.route() does not
|
||||
* support streaming responses.
|
||||
*
|
||||
* Must be called BEFORE frigateApp.goto(). The override also exposes
|
||||
* `__chatRequests` on window so tests can assert the outgoing body.
|
||||
*/
|
||||
async function installChatStreamOverride(
|
||||
app: FrigateApp,
|
||||
chunks: Array<Record<string, unknown>>,
|
||||
opts: { chunkDelayMs?: number; status?: number } = {},
|
||||
) {
|
||||
const { chunkDelayMs = 40, status = 200 } = opts;
|
||||
await app.page.addInitScript(
|
||||
({ chunks, chunkDelayMs, status }) => {
|
||||
(window as unknown as { __chatRequests: unknown[] }).__chatRequests = [];
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: (input as Request).url;
|
||||
if (url.includes("chat/completion")) {
|
||||
const body =
|
||||
init?.body instanceof String || typeof init?.body === "string"
|
||||
? JSON.parse(init!.body as string)
|
||||
: null;
|
||||
(
|
||||
window as unknown as { __chatRequests: unknown[] }
|
||||
).__chatRequests.push({ url, body });
|
||||
if (status !== 200) {
|
||||
return new Response(JSON.stringify({ error: "boom" }), {
|
||||
status,
|
||||
});
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
await new Promise((r) => setTimeout(r, chunkDelayMs));
|
||||
controller.enqueue(
|
||||
encoder.encode(JSON.stringify(chunk) + "\n"),
|
||||
);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
}
|
||||
return origFetch.call(window, input as RequestInfo, init);
|
||||
};
|
||||
},
|
||||
{ chunks, chunkDelayMs, status },
|
||||
);
|
||||
}
|
||||
|
||||
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
|
||||
test.describe("Chat — starting state @medium", () => {
|
||||
test("empty message list renders ChatStartingState with title and input", 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);
|
||||
}
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 1 }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible();
|
||||
// Four quick-reply buttons from starting_requests.*
|
||||
const quickReplies = frigateApp.page.locator(
|
||||
"button:has-text('Show recent events'), button:has-text('Show camera status'), button:has-text('What happened'), button:has-text('Watch')",
|
||||
);
|
||||
await expect(quickReplies.first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — streaming @medium", () => {
|
||||
test("submission POSTs to chat/completion with stream: true", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: "Hel" },
|
||||
{ type: "content", delta: "lo" },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("hello chat");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
frigateApp.page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __chatRequests: unknown[] })
|
||||
.__chatRequests?.length ?? 0,
|
||||
),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const request = await frigateApp.page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__chatRequests: Array<{
|
||||
url: string;
|
||||
body: { stream: boolean; messages: Array<{ content: string }> };
|
||||
}>;
|
||||
}
|
||||
).__chatRequests[0],
|
||||
);
|
||||
expect(request.body.stream).toBe(true);
|
||||
expect(
|
||||
request.body.messages[request.body.messages.length - 1].content,
|
||||
).toBe("hello chat");
|
||||
});
|
||||
|
||||
test("NDJSON content chunks accumulate in the assistant bubble", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installChatStreamOverride(
|
||||
frigateApp,
|
||||
[
|
||||
{ type: "content", delta: "Hel" },
|
||||
{ type: "content", delta: "lo, " },
|
||||
{ type: "content", delta: "world!" },
|
||||
],
|
||||
{ chunkDelayMs: 50 },
|
||||
);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("greet me");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(frigateApp.page.getByText(/Hello, world!/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => {
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{
|
||||
type: "tool_calls",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
name: "search_objects",
|
||||
arguments: { label: "person" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "content", delta: "Searching for people." },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("find people");
|
||||
await input.press("Enter");
|
||||
|
||||
// ToolCallsGroup normalizes "search_objects" → "Search Objects" via
|
||||
// normalizeName(). Match the rendered display label instead.
|
||||
await expect(frigateApp.page.getByText(/search objects/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByText(/searching for people/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — stop @medium", () => {
|
||||
test("Stop button aborts an in-flight stream and freezes the partial message", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// A long chunk sequence with big delays gives us time to hit Stop.
|
||||
await installChatStreamOverride(
|
||||
frigateApp,
|
||||
[
|
||||
{ type: "content", delta: "First chunk. " },
|
||||
{ type: "content", delta: "Second chunk. " },
|
||||
{ type: "content", delta: "Third chunk. " },
|
||||
],
|
||||
{ chunkDelayMs: 300 },
|
||||
);
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("slow response please");
|
||||
await input.press("Enter");
|
||||
|
||||
// Wait for the first chunk to render
|
||||
await expect(frigateApp.page.getByText(/First chunk\./)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The Stop button is a destructive rounded button shown while isLoading.
|
||||
// It contains only an FaStop SVG icon (no visible text). Find it by the
|
||||
// destructive variant class or fall back to aria-label.
|
||||
const stopBtn = frigateApp.page
|
||||
.locator("button.bg-destructive, button[class*='destructive']")
|
||||
.first();
|
||||
await stopBtn.click({ timeout: 3_000 }).catch(async () => {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /stop|cancel/i })
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Third chunk should never appear.
|
||||
await expect(frigateApp.page.getByText(/Third chunk\./)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — error @medium", () => {
|
||||
test("non-OK response renders an error banner", async ({ frigateApp }) => {
|
||||
await installChatStreamOverride(frigateApp, [], { status: 500 });
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("trigger error");
|
||||
await input.press("Enter");
|
||||
// The error banner is a role="alert" paragraph; target by role so we
|
||||
// don't collide with the user-message bubble that contains "trigger
|
||||
// error" (which would match /error/ in strict mode).
|
||||
await expect(
|
||||
frigateApp.page.getByRole("alert").filter({
|
||||
hasText: /boom|something went wrong/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — attachment chip @medium", () => {
|
||||
test("attaching an event renders a ChatAttachmentChip", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The chat starts with an empty message list (ChatStartingState).
|
||||
// After sending a message, ChatEntry with the paperclip button appears.
|
||||
// We use the stream override so the first message completes quickly.
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: "Done." },
|
||||
]);
|
||||
await frigateApp.goto("/chat");
|
||||
|
||||
// Send a first message to transition out of ChatStartingState so the
|
||||
// full ChatEntry (with the paperclip) is visible.
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("hello");
|
||||
await input.press("Enter");
|
||||
// Wait for the assistant response to complete so isLoading becomes false
|
||||
// and the paperclip button is re-enabled.
|
||||
await expect(frigateApp.page.getByText(/Done\./i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The paperclip button has aria-label from t("attachment_picker_placeholder")
|
||||
// = "Attach an event".
|
||||
const paperclip = frigateApp.page
|
||||
.getByRole("button", { name: /attach an event/i })
|
||||
.first();
|
||||
await expect(paperclip).toBeVisible({ timeout: 5_000 });
|
||||
await paperclip.click();
|
||||
|
||||
// The popover shows a paste input with placeholder "Or paste event ID".
|
||||
const idInput = frigateApp.page
|
||||
.locator('input[placeholder*="event" i], input[aria-label*="attach" i]')
|
||||
.first();
|
||||
await expect(idInput).toBeVisible({ timeout: 3_000 });
|
||||
await idInput.fill("test-event-1");
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^attach$/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The ChatAttachmentChip renders in the composer area. It shows an
|
||||
// activity indicator while loading event data (event_ids API not mocked),
|
||||
// so assert on the chip container being present in the composer.
|
||||
await expect(
|
||||
frigateApp.page.locator(
|
||||
"[class*='inline-flex'][class*='rounded-lg'][class*='border']",
|
||||
),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("chat input is focusable at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.focus();
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,33 +1,228 @@
|
||||
/**
|
||||
* Classification page tests -- MEDIUM tier.
|
||||
*
|
||||
* Tests model selection view rendering and interactive elements.
|
||||
* Model list driven by config.classification.custom + per-model
|
||||
* dataset fetches. Admin-only access.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
test.describe("Classification @medium", () => {
|
||||
test("classification page renders without crash", async ({ frigateApp }) => {
|
||||
const CUSTOM_MODELS = {
|
||||
object_classifier: {
|
||||
name: "object_classifier",
|
||||
object_config: { objects: ["person"], classification_type: "sub_label" },
|
||||
},
|
||||
state_classifier: {
|
||||
name: "state_classifier",
|
||||
state_config: { cameras: { front_door: { crop: [0, 0, 1, 1] } } },
|
||||
},
|
||||
};
|
||||
|
||||
async function installDatasetRoute(
|
||||
app: { page: import("@playwright/test").Page },
|
||||
name: string,
|
||||
body: Record<string, unknown> = { categories: {} },
|
||||
) {
|
||||
await app.page.route(
|
||||
new RegExp(`/api/classification/${name}/dataset`),
|
||||
(route) => route.fulfill({ json: body }),
|
||||
);
|
||||
}
|
||||
|
||||
async function installTrainRoute(
|
||||
app: { page: import("@playwright/test").Page },
|
||||
name: string,
|
||||
) {
|
||||
await app.page.route(
|
||||
new RegExp(`/api/classification/${name}/train`),
|
||||
(route) => route.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Classification — model list @medium", () => {
|
||||
test("custom models render by name", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { classification: { custom: CUSTOM_MODELS } },
|
||||
});
|
||||
await installDatasetRoute(frigateApp, "object_classifier");
|
||||
await installDatasetRoute(frigateApp, "state_classifier");
|
||||
await frigateApp.goto("/classification");
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("classification page shows content and controls", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test("empty custom map renders without crash", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { classification: { custom: {} } },
|
||||
});
|
||||
await frigateApp.goto("/classification");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
const text = await frigateApp.page.textContent("#pageRoot");
|
||||
expect(text?.length).toBeGreaterThan(0);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("classification page has interactive elements", async ({
|
||||
test("toggling to states view switches the rendered card set", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { classification: { custom: CUSTOM_MODELS } },
|
||||
});
|
||||
await installDatasetRoute(frigateApp, "object_classifier");
|
||||
await installDatasetRoute(frigateApp, "state_classifier");
|
||||
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);
|
||||
// Objects is default — object_classifier visible, state_classifier hidden.
|
||||
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0);
|
||||
|
||||
// Click the "states" toggle. Radix ToggleGroup type="single" uses role="radio".
|
||||
const statesToggle = frigateApp.page
|
||||
.getByRole("radio", { name: /state/i })
|
||||
.first();
|
||||
await expect(statesToggle).toBeVisible({ timeout: 5_000 });
|
||||
await statesToggle.click();
|
||||
|
||||
await expect(frigateApp.page.getByText("state_classifier")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(frigateApp.page.getByText("object_classifier")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Classification — model detail navigation @medium", () => {
|
||||
test("clicking a model card opens ModelTrainingView", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { classification: { custom: CUSTOM_MODELS } },
|
||||
});
|
||||
await installDatasetRoute(frigateApp, "object_classifier");
|
||||
await installDatasetRoute(frigateApp, "state_classifier");
|
||||
await installTrainRoute(frigateApp, "object_classifier");
|
||||
await frigateApp.goto("/classification");
|
||||
|
||||
const objectCard = frigateApp.page.getByText("object_classifier").first();
|
||||
await expect(objectCard).toBeVisible({ timeout: 10_000 });
|
||||
await objectCard.click();
|
||||
|
||||
// ModelTrainingView renders a Back button (aria-label "Back").
|
||||
// useOverlayState stores the selected model in window.history.state
|
||||
// (not the URL), so we verify the state transition via the DOM.
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /back/i }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// The model grid is no longer shown; state_classifier card is gone.
|
||||
await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Classification — delete model (desktop) @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Delete action menu is desktop-focused",
|
||||
);
|
||||
|
||||
test("deleting a model fires DELETE + PUT /config/set", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
let deleteCalled = false;
|
||||
let configSetCalled = false;
|
||||
|
||||
// installDefaults must run first because Playwright matches routes in
|
||||
// LIFO order — routes registered after installDefaults take precedence
|
||||
// over the generic catch-all registered inside it.
|
||||
await frigateApp.installDefaults({
|
||||
config: { classification: { custom: CUSTOM_MODELS } },
|
||||
});
|
||||
await installDatasetRoute(frigateApp, "object_classifier");
|
||||
await installDatasetRoute(frigateApp, "state_classifier");
|
||||
|
||||
// Register spy routes after installDefaults so they win over the catch-all.
|
||||
await frigateApp.page.route(
|
||||
/\/api\/classification\/object_classifier$/,
|
||||
async (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
deleteCalled = true;
|
||||
await route.fulfill({ json: { success: true } });
|
||||
return;
|
||||
}
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
await frigateApp.page.route("**/api/config/set", async (route) => {
|
||||
if (route.request().method() === "PUT") configSetCalled = true;
|
||||
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
await frigateApp.goto("/classification");
|
||||
await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The card-level actions menu (FiMoreVertical three-dot icon) is a
|
||||
// DropdownMenuTrigger with asChild on a BlurredIconButton div.
|
||||
// Radix forwards aria-haspopup="menu" to the child element.
|
||||
// Scope the selector to the model card grid to avoid hitting the
|
||||
// settings sidebar trigger.
|
||||
const cardGrid = frigateApp.page.locator(".grid.auto-rows-max");
|
||||
await expect(cardGrid).toBeVisible({ timeout: 5_000 });
|
||||
const trigger = cardGrid.locator('[aria-haspopup="menu"]').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 5_000 });
|
||||
await trigger.click();
|
||||
const deleteItem = frigateApp.page
|
||||
.getByRole("menuitem", { name: /delete/i })
|
||||
.first();
|
||||
await expect(deleteItem).toBeVisible({ timeout: 5_000 });
|
||||
await deleteItem.click();
|
||||
|
||||
// Confirm the AlertDialog.
|
||||
const alert = frigateApp.page.getByRole("alertdialog");
|
||||
await expect(alert).toBeVisible({ timeout: 5_000 });
|
||||
await alert
|
||||
.getByRole("button", { name: /delete|confirm/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect.poll(() => deleteCalled, { timeout: 5_000 }).toBe(true);
|
||||
await expect.poll(() => configSetCalled, { timeout: 5_000 }).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Classification — admin only @medium", () => {
|
||||
test("viewer navigating to /classification is redirected to access-denied", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||
await frigateApp.page.goto("/classification");
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(frigateApp.page).toHaveURL(/\/unauthorized/, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /access denied/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Classification — mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("page renders at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { classification: { custom: CUSTOM_MODELS } },
|
||||
});
|
||||
await installDatasetRoute(frigateApp, "object_classifier");
|
||||
await installDatasetRoute(frigateApp, "state_classifier");
|
||||
await frigateApp.goto("/classification");
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,44 +1,276 @@
|
||||
/**
|
||||
* Config Editor page tests -- MEDIUM tier.
|
||||
* Config Editor tests -- MEDIUM tier.
|
||||
*
|
||||
* Tests Monaco editor loading, YAML content rendering,
|
||||
* save button presence, and copy button interaction.
|
||||
* Monaco load + value, Save (config/save?save_option=saveonly),
|
||||
* Save error path, Save and Restart (WS frame via useRestart),
|
||||
* Copy (clipboard), schema markers.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames";
|
||||
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
|
||||
import {
|
||||
getMonacoVisibleText,
|
||||
replaceMonacoValue,
|
||||
waitForErrorMarker,
|
||||
} from "../helpers/monaco";
|
||||
|
||||
test.describe("Config Editor @medium", () => {
|
||||
test("config editor loads Monaco editor with content", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const SAMPLE_CONFIG =
|
||||
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n";
|
||||
|
||||
async function installSaveRoute(
|
||||
app: { page: import("@playwright/test").Page },
|
||||
status: number,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{
|
||||
capturedUrl: () => string | null;
|
||||
capturedBody: () => string | null;
|
||||
}> {
|
||||
let lastUrl: string | null = null;
|
||||
let lastBody: string | null = null;
|
||||
await app.page.route("**/api/config/save**", async (route) => {
|
||||
lastUrl = route.request().url();
|
||||
lastBody = route.request().postData();
|
||||
await route.fulfill({ status, json: body });
|
||||
});
|
||||
return {
|
||||
capturedUrl: () => lastUrl,
|
||||
capturedBody: () => lastBody,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Config Editor — Monaco @medium", () => {
|
||||
test("editor loads with mocked configRaw content", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
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(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
// Assert via DOM-rendered visible text (Monaco virtualizes — works
|
||||
// for short configs which covers our mocked content).
|
||||
await expect
|
||||
.poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 })
|
||||
.toContain("front_door");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Editor — Save @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Save button copy is desktop-visible (hidden md:block)",
|
||||
);
|
||||
|
||||
test("clicking Save Only POSTs config/save?save_option=saveonly", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
const capture = await installSaveRoute(frigateApp, 200, {
|
||||
message: "Config saved",
|
||||
});
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Save Only").click();
|
||||
await expect
|
||||
.poll(() => capture.capturedUrl(), { timeout: 5_000 })
|
||||
.toMatch(/config\/save\?save_option=saveonly/);
|
||||
// Body is the raw YAML as text/plain
|
||||
await expect
|
||||
.poll(() => capture.capturedBody(), { timeout: 5_000 })
|
||||
.toContain("front_door");
|
||||
});
|
||||
|
||||
test("Save error shows the server message in the error area", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
await installSaveRoute(frigateApp, 400, {
|
||||
message: "Invalid field `cameras.front_door`",
|
||||
});
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.getByLabel("Save Only").click();
|
||||
await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Editor — Save and Restart @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Save and Restart button copy is desktop-visible",
|
||||
);
|
||||
|
||||
test("Save and Restart opens dialog; confirm sends WS restart frame", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
await installSaveRoute(frigateApp, 200, { message: "Saved" });
|
||||
await installWsFrameCapture(frigateApp.page);
|
||||
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await frigateApp.page.getByLabel("Save & Restart").click();
|
||||
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await dialog.getByRole("button", { name: /restart/i }).click();
|
||||
await waitForWsFrame(
|
||||
frigateApp.page,
|
||||
(frame) => frame.includes('"restart"') || frame.includes("restart"),
|
||||
{ message: "useRestart should send a WS frame on the restart topic" },
|
||||
);
|
||||
});
|
||||
|
||||
test("cancelling the restart dialog leaves body interactive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
await installSaveRoute(frigateApp, 200, { message: "Saved" });
|
||||
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await frigateApp.page.getByLabel("Save & Restart").click();
|
||||
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.getByRole("button", { name: /cancel/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||
await expect(
|
||||
frigateApp.page.locator(".monaco-editor").first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Editor — Copy @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Copy button copy is desktop-visible",
|
||||
);
|
||||
|
||||
test("Copy places the editor value in the clipboard", async ({
|
||||
frigateApp,
|
||||
context,
|
||||
}) => {
|
||||
await grantClipboardPermissions(context);
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await frigateApp.page.getByLabel("Copy Config").click();
|
||||
await expect
|
||||
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
|
||||
.toContain("front_door");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Editor — schema markers @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Schema validation assumes focused desktop editing",
|
||||
);
|
||||
|
||||
test("invalid YAML renders at least one error marker in the DOM", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// Replace editor contents with clearly invalid YAML via keyboard.
|
||||
await replaceMonacoValue(
|
||||
frigateApp.page,
|
||||
"this is not: [yaml: and has {unbalanced",
|
||||
);
|
||||
// Monaco debounces marker evaluation; the .squiggly-error decoration
|
||||
// appears asynchronously in the .view-overlays layer.
|
||||
await waitForErrorMarker(frigateApp.page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Editor — Cmd+S keyboard shortcut @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Keyboard save shortcut is desktop-only",
|
||||
);
|
||||
|
||||
test("Cmd/Ctrl+S fires the same config/save POST as the Save button", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
const capture = await installSaveRoute(frigateApp, 200, {
|
||||
message: "Saved",
|
||||
});
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// Focus the editor so Monaco's keybinding receives the shortcut.
|
||||
await frigateApp.page.locator(".monaco-editor").first().click();
|
||||
await frigateApp.page.keyboard.press("ControlOrMeta+s");
|
||||
|
||||
await expect
|
||||
.poll(() => capture.capturedUrl(), { timeout: 5_000 })
|
||||
.toMatch(/config\/save\?save_option=saveonly/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Editor — Safe Mode auto-validation @medium", () => {
|
||||
test("safe-mode config auto-posts on mount and shows the inline error", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Thread safe_mode: true through the config override, then stub
|
||||
// config/save to return a validation error. The page's
|
||||
// initialValidationRef effect runs on mount and POSTs
|
||||
// config/save?save_option=saveonly with the raw config; the 400
|
||||
// surfaces through setError.
|
||||
// installDefaults must come first so our specific route wins (LIFO).
|
||||
await frigateApp.installDefaults({
|
||||
config: { safe_mode: true } as unknown as Record<string, unknown>,
|
||||
configRaw: "cameras:\n front_door:\n ffmpeg: {}\n",
|
||||
});
|
||||
let autoSaveCalled = false;
|
||||
await frigateApp.page.route("**/api/config/save**", async (route) => {
|
||||
autoSaveCalled = true;
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
json: { message: "safe-mode validation failure" },
|
||||
});
|
||||
});
|
||||
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect.poll(() => autoSaveCalled, { timeout: 10_000 }).toBe(true);
|
||||
await expect(
|
||||
frigateApp.page.getByText(/safe-mode validation failure/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Editor — mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("editor renders at narrow viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||
await frigateApp.goto("/config");
|
||||
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,97 +1,265 @@
|
||||
/**
|
||||
* 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.
|
||||
* Search input, Enter submission, camera filter popover (desktop),
|
||||
* event grid rendering with mocked events, mobile filter drawer.
|
||||
*
|
||||
* DEVIATION NOTES (from original plan):
|
||||
*
|
||||
* 1. Search input: InputWithTags is only rendered when
|
||||
* config.semantic_search.enabled is true. Tests that exercise the search
|
||||
* input override the config accordingly, using model:"genai" (not in the
|
||||
* JINA_EMBEDDING_MODELS list) so the page skips local model-state checks
|
||||
* and renders without waiting for model-download WS messages.
|
||||
*
|
||||
* 2. Filter buttons (Cameras, Labels, More Filters): SearchFilterGroup is
|
||||
* only rendered when hasExistingSearch is true. Tests navigate with a URL
|
||||
* param (?labels=person) to surface the filter bar.
|
||||
*
|
||||
* 3. Cameras button: accessible name is "Cameras Filter" (aria-label), not
|
||||
* "All Cameras" (inner text). Use getByLabel("Cameras Filter").
|
||||
*
|
||||
* 4. Labels: button accessible name is "Labels" (aria-label). With
|
||||
* ?labels=person, the text shows "Person" rather than "All Labels".
|
||||
* Use getByLabel("Labels").
|
||||
*
|
||||
* 5. Sub-labels / Zones: These live inside the "More Filters" dialog
|
||||
* (SearchFilterDialog), not as standalone top-level buttons. The Zones
|
||||
* test opens "More Filters" and asserts zone content from config.
|
||||
*
|
||||
* 6. similarity_search_id URL param: This param does not exist in the app.
|
||||
* The correct entrypoint for similarity search is
|
||||
* ?search_type=similarity&event_id=<id>. The test uses this URL and
|
||||
* polls for the resulting API request.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
|
||||
test.describe("Explore Page - Search @high", () => {
|
||||
test("explore page renders with filter buttons", async ({ frigateApp }) => {
|
||||
// Semantic search config override used by multiple tests. Using model:
|
||||
// "genai" (not in JINA_EMBEDDING_MODELS) sets isGenaiEmbeddings=true, which
|
||||
// skips local model-state checks and lets the page render without waiting for
|
||||
// individual model download WS messages. The WS mocker returns a completed
|
||||
// reindexState so !reindexState is false and the loading gate clears.
|
||||
const SEMANTIC_SEARCH_CONFIG = {
|
||||
semantic_search: { enabled: true, model: "genai" },
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search input (semantic_search must be enabled)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore — search @high", () => {
|
||||
test("search input accepts text and clears", async ({ frigateApp }) => {
|
||||
// Enable semantic search so InputWithTags renders.
|
||||
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||
await frigateApp.goto("/explore");
|
||||
const searchInput = frigateApp.page.locator("input").first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill("person");
|
||||
await expect(searchInput).toHaveValue("person");
|
||||
await searchInput.fill("");
|
||||
await expect(searchInput).toHaveValue("");
|
||||
});
|
||||
|
||||
test("Enter submission does not crash the page", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||
await frigateApp.goto("/explore");
|
||||
const searchInput = frigateApp.page.locator("input").first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill("car in driveway");
|
||||
await searchInput.press("Enter");
|
||||
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");
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter bar — desktop only
|
||||
// Filter buttons appear once hasExistingSearch is true (URL params present).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore — filters (desktop) @high", () => {
|
||||
test.skip(({ frigateApp }) => frigateApp.isMobile, "Desktop popovers");
|
||||
|
||||
test("Cameras popover lists configured cameras", async ({ frigateApp }) => {
|
||||
// Navigate with a labels filter param so the filter bar renders.
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
// CamerasFilterButton has aria-label="Cameras Filter". Use getByLabel to
|
||||
// match against the accessible name (not the inner "All Cameras" text).
|
||||
const camerasBtn = frigateApp.page.getByLabel("Cameras Filter").first();
|
||||
await expect(camerasBtn).toBeVisible({ timeout: 10_000 });
|
||||
await camerasBtn.click();
|
||||
// DropdownMenu on desktop wraps content in data-radix-popper-content-wrapper.
|
||||
const popover = frigateApp.page.locator(
|
||||
"[data-radix-popper-content-wrapper]",
|
||||
);
|
||||
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
||||
});
|
||||
|
||||
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);
|
||||
test("Labels filter lists labels from config", async ({ frigateApp }) => {
|
||||
// Navigate with an existing search so the filter bar renders.
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
// GeneralFilterButton has aria-label="Labels". With ?labels=person the
|
||||
// button text shows "Person" (the selected label), but the aria-label
|
||||
// remains "Labels".
|
||||
const labelsBtn = frigateApp.page.getByLabel("Labels").first();
|
||||
await expect(labelsBtn).toBeVisible({ timeout: 10_000 });
|
||||
await labelsBtn.click();
|
||||
// PlatformAwareDialog renders on desktop as a dropdown/popover overlay.
|
||||
const overlay = frigateApp.page.locator(
|
||||
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||
);
|
||||
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||
// "person" is already selected (it's in the URL); assert it appears in
|
||||
// the overlay content.
|
||||
await expect(overlay.first().getByText(/person/i)).toBeVisible();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Sub-labels filter renders inside More Filters dialog", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Sub-labels live inside SearchFilterDialog ("More Filters" button).
|
||||
// With sub_labels mocked as [], the section still renders its heading.
|
||||
await frigateApp.page.route("**/api/sub_labels**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
|
||||
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
|
||||
await moreBtn.click();
|
||||
const overlay = frigateApp.page.locator(
|
||||
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||
);
|
||||
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||
// "Sub Labels" section heading always renders inside the dialog.
|
||||
await expect(
|
||||
frigateApp.page.getByText(/sub.?label/i).first(),
|
||||
).toBeVisible();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Zones filter lists configured zones inside More Filters dialog", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Override config to guarantee a known zone on front_door.
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
const moreBtn = frigateApp.page.getByLabel("More Filters").first();
|
||||
await expect(moreBtn).toBeVisible({ timeout: 10_000 });
|
||||
await moreBtn.click();
|
||||
const overlay = frigateApp.page.locator(
|
||||
"[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']",
|
||||
);
|
||||
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||
await expect(frigateApp.page.getByText(/front.?yard/i)).toBeVisible();
|
||||
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 ({
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore — content @high", () => {
|
||||
test("page renders with mock events", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/explore");
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.locator("#pageRoot button").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("empty events renders without crash", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ events: [] });
|
||||
await frigateApp.goto("/explore");
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("search fires a /api/events request with the query", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||
const eventsRequests: string[] = [];
|
||||
frigateApp.page.on("request", (req) => {
|
||||
const url = req.url();
|
||||
if (/\/api\/events/.test(url)) eventsRequests.push(url);
|
||||
});
|
||||
await frigateApp.goto("/explore");
|
||||
await frigateApp.page.waitForTimeout(3000);
|
||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
||||
expect(pageText?.length).toBeGreaterThan(0);
|
||||
const searchInput = frigateApp.page.locator("input").first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const before = eventsRequests.length;
|
||||
await searchInput.fill("person in driveway");
|
||||
await searchInput.press("Enter");
|
||||
await expect
|
||||
.poll(() => eventsRequests.length > before, { timeout: 5_000 })
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Similarity search URL param
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore — similarity search (desktop) @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Similarity trigger is hover-based; desktop-focused",
|
||||
);
|
||||
|
||||
test("URL similarity search params fetch events", async ({ frigateApp }) => {
|
||||
const eventsRequests: string[] = [];
|
||||
frigateApp.page.on("request", (req) => {
|
||||
const url = req.url();
|
||||
if (/\/api\/events/.test(url)) eventsRequests.push(url);
|
||||
});
|
||||
// The app uses search_type=similarity&event_id=<id> (not
|
||||
// similarity_search_id). This exercises the same similarity search code
|
||||
// path as clicking "Find Similar" on a thumbnail.
|
||||
// Use a valid event-id format (timestamp.fractional-alphanumeric).
|
||||
await frigateApp.goto(
|
||||
"/explore?search_type=similarity&event_id=1712412000.000000-abc123",
|
||||
);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Poll to allow any pending SWR fetch to complete and be captured.
|
||||
await expect
|
||||
.poll(() => eventsRequests.length, { timeout: 5_000 })
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore — mobile @high @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("search input is focusable at mobile viewport", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG });
|
||||
await frigateApp.goto("/explore");
|
||||
const searchInput = frigateApp.page.locator("input").first();
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.focus();
|
||||
await expect(searchInput).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,32 +1,390 @@
|
||||
/**
|
||||
* Face Library page tests -- MEDIUM tier.
|
||||
* Face Library page tests -- HIGH tier.
|
||||
*
|
||||
* Tests face grid rendering, empty state, and interactive controls.
|
||||
* Collection selector, face tiles, grouped recent-recognition dialog
|
||||
* (migrated from radix-overlay-regressions.spec.ts), and mobile
|
||||
* library selector.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { type Locator } from "@playwright/test";
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import {
|
||||
basicFacesMock,
|
||||
emptyFacesMock,
|
||||
withGroupedTrainingAttempt,
|
||||
} from "../fixtures/mock-data/faces";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
|
||||
test.describe("Face Library @medium", () => {
|
||||
test("face library page renders without crash", async ({ frigateApp }) => {
|
||||
const GROUPED_EVENT_ID = "1775487131.3863528-abc123";
|
||||
|
||||
function groupedFacesMock() {
|
||||
return withGroupedTrainingAttempt(basicFacesMock(), {
|
||||
eventId: GROUPED_EVENT_ID,
|
||||
attempts: [
|
||||
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
|
||||
{ timestamp: 1775487132.3863528, label: "unknown", score: 0.91 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function installGroupedFaces(app: FrigateApp) {
|
||||
await app.api.install({
|
||||
events: [
|
||||
{
|
||||
id: GROUPED_EVENT_ID,
|
||||
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",
|
||||
path_data: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
faces: groupedFacesMock(),
|
||||
});
|
||||
}
|
||||
|
||||
async function openGroupedFaceDialog(app: FrigateApp): Promise<Locator> {
|
||||
await installGroupedFaces(app);
|
||||
await app.goto("/faces");
|
||||
const groupedImage = app.page
|
||||
.locator('img[src*="clips/faces/train/"]')
|
||||
.first();
|
||||
const groupedCard = groupedImage.locator("xpath=..");
|
||||
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
|
||||
await groupedCard.click();
|
||||
const dialog = app.page
|
||||
.getByRole("dialog")
|
||||
.filter({ has: app.page.locator('img[src*="clips/faces/train/"]') })
|
||||
.first();
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog.locator('img[src*="clips/faces/train/"]')).toHaveCount(2);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the LibrarySelector dropdown (the single button at the top-left of
|
||||
* the Face Library page) and returns the dropdown menu locator.
|
||||
*
|
||||
* The LibrarySelector is a single DropdownMenu whose trigger shows the
|
||||
* current tab name + count (e.g. "Recent Recognitions (0)"). Named face
|
||||
* collections (alice, bob, charlie) are items inside this dropdown.
|
||||
*/
|
||||
async function openLibraryDropdown(app: FrigateApp): Promise<Locator> {
|
||||
// The trigger is the first button on the page with a parenthesised count.
|
||||
const trigger = app.page
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /\(\d+\)/ })
|
||||
.first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const menu = app.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
return menu;
|
||||
}
|
||||
|
||||
test.describe("Face Library — collection selector @high", () => {
|
||||
test("selector shows named face collections", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||
await frigateApp.goto("/faces");
|
||||
// Named collections appear in the LibrarySelector dropdown.
|
||||
const menu = await openLibraryDropdown(frigateApp);
|
||||
await expect(menu.getByText(/alice/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("empty state renders when no faces exist", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ faces: emptyFacesMock() });
|
||||
await frigateApp.goto("/faces");
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('img[src*="/clips/faces/"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("face library shows empty state with no faces", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test("tiles render for each named collection", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||
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);
|
||||
// Open the dropdown — collections list shows "alice (2)" and "bob (1)".
|
||||
const menu = await openLibraryDropdown(frigateApp);
|
||||
await expect(
|
||||
menu.locator('[role="menuitem"]').filter({ hasText: /alice/i }).first(),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
menu.locator('[role="menuitem"]').filter({ hasText: /bob/i }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library — delete flow (desktop) @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Delete action menu is desktop-focused",
|
||||
);
|
||||
|
||||
test("deleting a collection fires POST /faces/<name>/delete", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
let deleteUrl: string | null = null;
|
||||
let deleteBody: unknown = null;
|
||||
// Install base mocks first, then register our more-specific route AFTER
|
||||
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
|
||||
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||
await frigateApp.page.route(
|
||||
/\/api\/faces\/[^/]+\/delete/,
|
||||
async (route) => {
|
||||
deleteUrl = route.request().url();
|
||||
deleteBody = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
// Open the LibrarySelector dropdown and click the trash icon next
|
||||
// to the alice row. The trash icon is a ghost-variant Button inside
|
||||
// the DropdownMenuItem — it becomes visible on hover/focus.
|
||||
const menu = await openLibraryDropdown(frigateApp);
|
||||
const aliceRow = menu
|
||||
.locator('[role="menuitem"]')
|
||||
.filter({ hasText: /alice/i })
|
||||
.first();
|
||||
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
|
||||
// Hover first to make hover-only opacity-0 buttons visible.
|
||||
await aliceRow.hover();
|
||||
// The icon buttons have no aria-label or title. The row renders exactly
|
||||
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
|
||||
// (delete). This order is determined by FaceLibrary.tsx and is stable.
|
||||
const trashBtn = aliceRow.locator("button").nth(1);
|
||||
await trashBtn.click();
|
||||
|
||||
// The delete confirmation is a Dialog (not AlertDialog) in this flow.
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await dialog
|
||||
.getByRole("button", { name: /delete/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => deleteUrl, { timeout: 5_000 })
|
||||
.toMatch(/\/faces\/alice\/delete/);
|
||||
expect(deleteBody).toMatchObject({ ids: expect.any(Array) });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library — rename flow (desktop) @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Rename action menu is desktop-focused",
|
||||
);
|
||||
|
||||
test("renaming a collection fires PUT /faces/<name>/rename", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
let renameUrl: string | null = null;
|
||||
let renameBody: unknown = null;
|
||||
// Install base mocks first, then register our more-specific route AFTER
|
||||
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
|
||||
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||
await frigateApp.page.route(
|
||||
/\/api\/faces\/[^/]+\/rename/,
|
||||
async (route) => {
|
||||
renameUrl = route.request().url();
|
||||
renameBody = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
// Open the LibrarySelector dropdown and click the pencil (rename) icon
|
||||
// next to alice. The icon is a ghost Button inside the DropdownMenuItem.
|
||||
const menu = await openLibraryDropdown(frigateApp);
|
||||
const aliceRow = menu
|
||||
.locator('[role="menuitem"]')
|
||||
.filter({ hasText: /alice/i })
|
||||
.first();
|
||||
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
|
||||
await aliceRow.hover();
|
||||
// The icon buttons have no aria-label or title. The row renders exactly
|
||||
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
|
||||
// (delete). This order is determined by FaceLibrary.tsx and is stable.
|
||||
const pencilBtn = aliceRow.locator("button").nth(0);
|
||||
await pencilBtn.click();
|
||||
|
||||
// TextEntryDialog — fill the input and confirm.
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.locator("input").first().fill("alice_renamed");
|
||||
await dialog
|
||||
.getByRole("button", { name: /save|rename|confirm/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => renameUrl, { timeout: 5_000 })
|
||||
.toMatch(/\/faces\/alice\/rename/);
|
||||
expect(renameBody).toEqual({ new_name: "alice_renamed" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library — upload flow @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Upload button has no accessible text on mobile — icon-only on narrow viewports",
|
||||
);
|
||||
|
||||
test("Upload button opens the upload dialog", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
// Navigate to the alice tab by opening the dropdown and clicking alice.
|
||||
const menu = await openLibraryDropdown(frigateApp);
|
||||
await menu
|
||||
.locator('[role="menuitem"]')
|
||||
.filter({ hasText: /alice/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// After switching to alice, the Upload Image button appears in the toolbar.
|
||||
const uploadBtn = frigateApp.page
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /upload/i })
|
||||
.first();
|
||||
await expect(uploadBtn).toBeVisible({ timeout: 5_000 });
|
||||
await uploadBtn.click();
|
||||
|
||||
// UploadImageDialog renders a file input + confirm button.
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog.locator('input[type="file"]')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("FaceSelectionDialog @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Grouped dropdown flow is desktop-only",
|
||||
);
|
||||
|
||||
test("reclassify dropdown selects a name and closes cleanly", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Migrated from radix-overlay-regressions.spec.ts.
|
||||
const dialog = await openGroupedFaceDialog(frigateApp);
|
||||
const triggers = dialog.locator('[aria-haspopup="menu"]');
|
||||
await expect(triggers).toHaveCount(2);
|
||||
|
||||
await triggers.first().click();
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
|
||||
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const tooltipVisible = await frigateApp.page
|
||||
.locator('[role="tooltip"]')
|
||||
.filter({ hasText: /train face/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(
|
||||
tooltipVisible,
|
||||
"Train Face tooltip popped after dropdown closed — focus-restore regression",
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("second dropdown open accepts typeahead keyboard input", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Migrated from radix-overlay-regressions.spec.ts.
|
||||
const dialog = await openGroupedFaceDialog(frigateApp);
|
||||
const triggers = dialog.locator('[aria-haspopup="menu"]');
|
||||
await expect(triggers).toHaveCount(2);
|
||||
|
||||
await triggers.first().click();
|
||||
let menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await triggers.nth(1).click();
|
||||
menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await frigateApp.page.keyboard.press("c");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
frigateApp.page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.textContent?.trim().toLowerCase() ?? "",
|
||||
),
|
||||
{ timeout: 2_000 },
|
||||
)
|
||||
.toMatch(/^charlie/);
|
||||
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library — mobile @high @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("mobile library selector dropdown closes cleanly on Escape", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Migrated from radix-overlay-regressions.spec.ts.
|
||||
await installGroupedFaces(frigateApp);
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
const selector = frigateApp.page
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /\(\d+\)/ })
|
||||
.first();
|
||||
await expect(selector).toBeVisible({ timeout: 5_000 });
|
||||
await selector.click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,59 +1,47 @@
|
||||
/**
|
||||
* 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.
|
||||
* Dashboard grid, single-camera controls, feature toggles (with WS
|
||||
* frame assertions), context menu, birdseye, and mobile layout.
|
||||
* Also absorbs the PTZ preset-dropdown regression tests from the
|
||||
* now-deleted ptz-overlay.spec.ts.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { LivePage } from "../pages/live.page";
|
||||
import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
|
||||
const PTZ_CAMERA = "front_door";
|
||||
const PRESET_NAMES = ["home", "driveway", "front_porch"];
|
||||
|
||||
test.describe("Live Dashboard @critical", () => {
|
||||
test("dashboard renders all configured cameras by name", async ({
|
||||
test("every configured camera renders on the dashboard", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/");
|
||||
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||
await expect(
|
||||
frigateApp.page.locator(`[data-camera='${cam}']`),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(live.cameraCard(cam)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking camera card opens single camera view via hash", async ({
|
||||
test("clicking a camera card opens the 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 });
|
||||
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||
await live.cameraCard("front_door").first().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 }) => {
|
||||
test("birdseye route renders without crash", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/#birdseye");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty group shows fallback content", async ({ frigateApp }) => {
|
||||
@ -63,191 +51,239 @@ test.describe("Live Dashboard @critical", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Live Single Camera - Controls @critical", () => {
|
||||
test("single camera view shows Back and History buttons (desktop)", async ({
|
||||
test.describe("Live Single Camera — desktop controls @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Desktop-only header controls",
|
||||
);
|
||||
|
||||
test("single-camera view shows Back and History buttons", 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();
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||
await expect(live.historyButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("single camera view shows feature toggle icons (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
test("feature toggles render (at least 3)", async ({ frigateApp }) => {
|
||||
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();
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
// Wait for the single-camera header to render before counting toggles.
|
||||
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||
await expect(live.featureToggles.first()).toBeVisible({ timeout: 5_000 });
|
||||
const count = await live.featureToggles.count();
|
||||
expect(count).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("clicking a feature toggle changes its visual state (desktop)", async ({
|
||||
test("clicking a feature toggle sends the matching WS frame", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await installWsFrameCapture(frigateApp.page);
|
||||
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 live = new LivePage(frigateApp.page, true);
|
||||
// Wait for feature toggles to render (WS camera_activity must arrive first).
|
||||
await expect(live.activeFeatureToggles.first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const activeBefore = await live.activeFeatureToggles.count();
|
||||
expect(activeBefore).toBeGreaterThan(0);
|
||||
|
||||
await live.activeFeatureToggles.first().click();
|
||||
|
||||
// The toggle dispatches a frame on <camera>/<feature>/set — match on
|
||||
// front_door/ prefix + /set suffix (any feature).
|
||||
await waitForWsFrame(
|
||||
frigateApp.page,
|
||||
(frame) => frame.includes("front_door/") && frame.includes("/set"),
|
||||
{
|
||||
message:
|
||||
"feature toggle should dispatch a <camera>/<feature>/set frame",
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
test("keyboard shortcut f does not crash", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
await frigateApp.page.keyboard.press("f");
|
||||
await frigateApp.page.waitForTimeout(500);
|
||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||
// Note: headless Chromium rejects fullscreen requests without a user
|
||||
// gesture, so document.fullscreenElement cannot be asserted reliably
|
||||
// in e2e. We assert the keypress doesn't crash the app; real
|
||||
// fullscreen behavior is covered by manual testing.
|
||||
});
|
||||
|
||||
test("settings gear opens a dropdown with Stream/Play menu items", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/#front_door");
|
||||
// Wait for the single-camera view to render — use the Back button
|
||||
// as a deterministic marker.
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The gear icon button is the last button-like element in the
|
||||
// single-camera header. Clicking it opens a Radix dropdown.
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
const count = await gearButtons.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Live Context Menu (desktop) @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Right-click is desktop-only",
|
||||
);
|
||||
|
||||
test("right-click opens the context menu", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
const menu = await live.openContextMenuOn("front_door");
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("context menu closes on Escape and leaves body interactive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/");
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
const menu = await live.openContextMenuOn("front_door");
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible();
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Live PTZ preset dropdown @critical", () => {
|
||||
// Migrated from ptz-overlay.spec.ts. Guards:
|
||||
// 1. After selecting a preset, the "Presets" tooltip must not re-pop.
|
||||
// 2. Keyboard shortcuts after close should not re-open the dropdown.
|
||||
|
||||
test("selecting a preset closes menu cleanly and does not re-open on keyboard", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "PTZ preset dropdown is desktop-only");
|
||||
|
||||
await frigateApp.api.install({
|
||||
config: {
|
||||
cameras: {
|
||||
[PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
name: PTZ_CAMERA,
|
||||
features: ["pt", "zoom"],
|
||||
presets: PRESET_NAMES,
|
||||
profiles: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await installWsFrameCapture(frigateApp.page);
|
||||
await frigateApp.goto(`/#${PTZ_CAMERA}`);
|
||||
|
||||
const presetTrigger = frigateApp.page.getByRole("button", {
|
||||
name: /presets/i,
|
||||
});
|
||||
await expect(presetTrigger.first()).toBeVisible({ timeout: 5_000 });
|
||||
await presetTrigger.first().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await menu.getByRole("menuitem", { name: PRESET_NAMES[0] }).first().click();
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await waitForWsFrame(
|
||||
frigateApp.page,
|
||||
(frame) =>
|
||||
frame.includes(`"${PTZ_CAMERA}/ptz"`) &&
|
||||
frame.includes(`preset_${PRESET_NAMES[0]}`),
|
||||
);
|
||||
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
frigateApp.page
|
||||
.locator('[role="tooltip"]')
|
||||
.filter({ hasText: /presets/i })
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
{ timeout: 1_000 },
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
await frigateApp.page.keyboard.press("ArrowUp");
|
||||
await frigateApp.page.keyboard.press("Space");
|
||||
await frigateApp.page.keyboard.press("Enter");
|
||||
await expect
|
||||
.poll(() => menu.isVisible().catch(() => false), { timeout: 1_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Live mobile layout @critical @mobile", () => {
|
||||
test("mobile dashboard has no sidebar and renders cameras", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
await frigateApp.goto("/");
|
||||
await expect(frigateApp.page.locator("aside")).toHaveCount(0);
|
||||
const live = new LivePage(frigateApp.page, false);
|
||||
await expect(live.cameraCard("front_door")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile camera tap opens single view", async ({ frigateApp }) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
await frigateApp.goto("/");
|
||||
const live = new LivePage(frigateApp.page, false);
|
||||
await live.cameraCard("front_door").first().click({ timeout: 10_000 });
|
||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
||||
});
|
||||
|
||||
test("mobile onvif single-camera view loads without freezing body", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
// Migrated from ptz-overlay.spec.ts — dismissable-layer dedupe smoke test.
|
||||
await frigateApp.api.install({
|
||||
config: {
|
||||
cameras: { [PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } } },
|
||||
},
|
||||
});
|
||||
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
name: PTZ_CAMERA,
|
||||
features: ["pt", "zoom"],
|
||||
presets: PRESET_NAMES,
|
||||
profiles: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await frigateApp.goto(`/#${PTZ_CAMERA}`);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,75 +1,222 @@
|
||||
/**
|
||||
* Logs page tests -- MEDIUM tier.
|
||||
*
|
||||
* Tests service tab switching by name, copy/download buttons,
|
||||
* and websocket message feed tab.
|
||||
* Service tabs (with real /logs/<service> JSON contract),
|
||||
* log content render, Copy (clipboard), Download (assert
|
||||
* ?download=true request fired), mobile tab selector.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
|
||||
|
||||
test.describe("Logs Page - Service Tabs @medium", () => {
|
||||
test("logs page renders with named service tabs", async ({ frigateApp }) => {
|
||||
function logsJsonBody(lines: string[]) {
|
||||
return { lines, totalLines: lines.length };
|
||||
}
|
||||
|
||||
test.describe("Logs — service tabs @medium", () => {
|
||||
test("frigate tab renders by default with mocked log lines", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
json: logsJsonBody([
|
||||
"[2026-04-06 10:00:00] INFO: Frigate started",
|
||||
"[2026-04-06 10:00:01] INFO: Cameras loaded",
|
||||
]),
|
||||
}),
|
||||
);
|
||||
// Silence the streaming fetch so it doesn't hang the test.
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||
route.fulfill({ status: 200, body: "" }),
|
||||
);
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching to go2rtc fires a GET to /logs/go2rtc", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
let go2rtcCalled = false;
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
|
||||
);
|
||||
await frigateApp.page.route(/\/api\/logs\/go2rtc(\?|$)/, (route) => {
|
||||
if (!route.request().url().includes("stream=true")) {
|
||||
go2rtcCalled = true;
|
||||
}
|
||||
return route.fulfill({ json: logsJsonBody(["go2rtc line"]) });
|
||||
});
|
||||
await frigateApp.page.route(/\/api\/logs\/.*\?stream=true/, (route) =>
|
||||
route.fulfill({ status: 200, body: "" }),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
|
||||
await expect(go2rtcTab).toBeVisible();
|
||||
await go2rtcTab.click();
|
||||
await expect.poll(() => go2rtcCalled, { timeout: 5_000 }).toBe(true);
|
||||
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Logs — actions @medium", () => {
|
||||
test("Copy button writes current logs to clipboard", async ({
|
||||
frigateApp,
|
||||
context,
|
||||
}) => {
|
||||
await grantClipboardPermissions(context);
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
json: logsJsonBody([
|
||||
"[2026-04-06 10:00:00] INFO: Frigate started",
|
||||
"[2026-04-06 10:00:01] INFO: Cameras loaded",
|
||||
]),
|
||||
}),
|
||||
);
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||
route.fulfill({ status: 200, body: "" }),
|
||||
);
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
|
||||
await expect(copyBtn).toBeVisible({ timeout: 5_000 });
|
||||
await copyBtn.click();
|
||||
await expect
|
||||
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
|
||||
.toContain("Frigate started");
|
||||
});
|
||||
|
||||
test("Download button fires GET /logs/<service>?download=true", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
let downloadCalled = false;
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => {
|
||||
if (route.request().url().includes("download=true")) {
|
||||
downloadCalled = true;
|
||||
}
|
||||
return route.fulfill({ json: logsJsonBody(["frigate line"]) });
|
||||
});
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||
route.fulfill({ status: 200, body: "" }),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/logs");
|
||||
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
|
||||
await expect(downloadBtn).toBeVisible({ timeout: 5_000 });
|
||||
await downloadBtn.click();
|
||||
await expect.poll(() => downloadCalled, { timeout: 5_000 }).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Logs — websocket tab @medium", () => {
|
||||
test("switching to websocket tab renders WsMessageFeed container", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
|
||||
);
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||
route.fulfill({ status: 200, body: "" }),
|
||||
);
|
||||
await frigateApp.goto("/logs");
|
||||
const wsTab = frigateApp.page.getByLabel("Select websocket");
|
||||
await expect(wsTab).toBeVisible({ timeout: 5_000 });
|
||||
await wsTab.click();
|
||||
await expect(wsTab).toHaveAttribute("data-state", "on", { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Logs — streaming @medium", () => {
|
||||
test("streamed log lines appear in the viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => {
|
||||
if (route.request().url().includes("stream=true")) {
|
||||
// Intercepted below via addInitScript fetch override.
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
json: logsJsonBody(["[2026-04-06 10:00:00] INFO: initial batch line"]),
|
||||
});
|
||||
});
|
||||
|
||||
// Override window.fetch so the /api/logs/frigate?stream=true request
|
||||
// resolves with a real ReadableStream that emits chunks over time.
|
||||
// This is the only way to validate streaming-append behavior through
|
||||
// Playwright — route.fulfill() cannot return a stream.
|
||||
// NOTE: The app calls fetch('api/logs/...') with a relative URL (no
|
||||
// leading slash), so we match both relative and absolute forms.
|
||||
await frigateApp.page.addInitScript(() => {
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: (input as Request).url;
|
||||
if (url.includes("api/logs/frigate") && url.includes("stream=true")) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
"[2026-04-06 10:00:02] INFO: streamed line one\n",
|
||||
),
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
"[2026-04-06 10:00:03] INFO: streamed line two\n",
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
}
|
||||
return origFetch.call(window, input as RequestInfo, init);
|
||||
};
|
||||
});
|
||||
|
||||
await frigateApp.goto("/logs");
|
||||
// The initial batch line is parsed by LogLineData and its content is
|
||||
// rendered in a .log-content cell — assert against that element.
|
||||
await expect(frigateApp.page.getByText("initial batch line")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(frigateApp.page.getByText(/streamed line one/)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(frigateApp.page.getByText(/streamed line two/)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Logs — mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("service tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) =>
|
||||
route.fulfill({ json: logsJsonBody(["frigate line"]) }),
|
||||
);
|
||||
await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) =>
|
||||
route.fulfill({ status: 200, body: "" }),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,103 +1,78 @@
|
||||
/**
|
||||
* Navigation tests -- CRITICAL tier.
|
||||
*
|
||||
* Tests sidebar (desktop) and bottombar (mobile) navigation,
|
||||
* conditional nav items, settings menus, and their actual behaviors.
|
||||
* Covers sidebar (desktop) / bottombar (mobile) link set, conditional
|
||||
* nav items (faces, chat, classification), settings menu navigation,
|
||||
* unknown-route redirect to /, and mobile-specific nav 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();
|
||||
});
|
||||
const PRIMARY_ROUTES = ["/review", "/explore", "/export"] as const;
|
||||
|
||||
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 ({
|
||||
test.describe("Navigation — primary links @critical", () => {
|
||||
test("every primary link is visible and navigates", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/");
|
||||
const routes = ["/review", "/explore", "/export"];
|
||||
for (const route of routes) {
|
||||
for (const route of PRIMARY_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) {
|
||||
for (const route of PRIMARY_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("logo links home on desktop", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Sidebar logo is desktop-only");
|
||||
await frigateApp.goto("/review");
|
||||
await frigateApp.page.locator("aside a[href='/']").first().click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
test("unknown route redirects to /", 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();
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-camera='front_door']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Navigation - Conditional Items @critical", () => {
|
||||
test("Faces nav hidden when face_recognition disabled", async ({
|
||||
test.describe("Navigation — conditional items @critical", () => {
|
||||
test("/faces is hidden when face_recognition.enabled is false", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/");
|
||||
await expect(frigateApp.page.locator('a[href="/faces"]')).not.toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/faces"]').first(),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Chat nav hidden when genai model is none", async ({ frigateApp }) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
test("/faces is visible when face_recognition.enabled is true (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: { face_recognition: { enabled: true } },
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/faces"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("/chat is hidden when genai.model is none (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
genai: {
|
||||
@ -109,119 +84,83 @@ test.describe("Navigation - Conditional Items @critical", () => {
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/chat"]').first(),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Faces nav visible when face_recognition enabled on desktop", async ({
|
||||
test("/chat is visible when genai.model is set (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;
|
||||
}
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: { genai: { enabled: true, model: "llava" } },
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
await expect(page.locator('a[href="/chat"]')).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/chat"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Classification nav visible for admin on desktop", async ({
|
||||
test("/classification is visible for admin on desktop", async ({
|
||||
frigateApp,
|
||||
page,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.goto("/");
|
||||
await expect(page.locator('a[href="/classification"]')).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/classification"]').first(),
|
||||
).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.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Sidebar settings menu is desktop-only",
|
||||
);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
test(`menu → ${target.label} navigates`, async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
const gearIcon = frigateApp.page
|
||||
const gear = 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, "\\$&")),
|
||||
);
|
||||
}
|
||||
await gear.click();
|
||||
await frigateApp.page.getByLabel(target.label).click();
|
||||
await expect(frigateApp.page).toHaveURL(target.url);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Navigation — mobile @critical @mobile", () => {
|
||||
test("mobile bottombar visible, sidebar not rendered", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
await frigateApp.goto("/");
|
||||
await expect(frigateApp.page.locator("aside")).toHaveCount(0);
|
||||
for (const route of PRIMARY_ROUTES) {
|
||||
await expect(
|
||||
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("account button in sidebar is clickable (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
test("mobile nav survives route change", async ({ frigateApp }) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
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();
|
||||
const reviewLink = frigateApp.page.locator('a[href="/review"]').first();
|
||||
await reviewLink.click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/review"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,198 +0,0 @@
|
||||
/**
|
||||
* PTZ overlay regression tests -- MEDIUM tier.
|
||||
*
|
||||
* Guards two things on the PTZ preset dropdown:
|
||||
*
|
||||
* 1. After selecting a preset, the "Presets" tooltip must not re-pop
|
||||
* (focus-restore side-effect that originally prompted the
|
||||
* `onCloseAutoFocus preventDefault` workaround).
|
||||
* 2. Keyboard shortcuts fired after the dropdown closes should not
|
||||
* re-open the dropdown via Space/Enter/Arrow on the trigger
|
||||
* (PR #12079 — "Prevent ptz keyboard shortcuts from reopening
|
||||
* presets menu").
|
||||
*
|
||||
* Requires an onvif-configured camera and a mocked /ptz/info endpoint
|
||||
* exposing presets.
|
||||
*
|
||||
* TODO: migrate these tests into live.spec.ts when it comes out of
|
||||
* PENDING_REWRITE in e2e/scripts/lint-specs.mjs. They live in a dedicated
|
||||
* file today so they stay lint-compliant (no waitForTimeout, no
|
||||
* conditional isVisible) while live.spec.ts is still exempt.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
|
||||
const PTZ_CAMERA = "front_door";
|
||||
const PRESET_NAMES = ["home", "driveway", "front_porch"];
|
||||
|
||||
test.describe("PTZ preset dropdown @medium", () => {
|
||||
test("selecting a preset closes menu cleanly and does not re-open on keyboard", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Give front_door an onvif host so the PtzControlPanel renders.
|
||||
// 2. Mock the /ptz/info endpoint to expose features + presets.
|
||||
await frigateApp.api.install({
|
||||
config: {
|
||||
cameras: {
|
||||
[PTZ_CAMERA]: {
|
||||
onvif: {
|
||||
host: "10.0.0.50",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
name: PTZ_CAMERA,
|
||||
features: ["pt", "zoom"],
|
||||
presets: PRESET_NAMES,
|
||||
profiles: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// PTZ commands ride the WebSocket, not HTTP. The WsMocker intercepts
|
||||
// the /ws route, so Playwright's page-level `websocket` event never
|
||||
// fires — instead, patch the client WebSocket.prototype.send before
|
||||
// any app code runs and mirror sends into a window-level array the
|
||||
// test can read back.
|
||||
await frigateApp.page.addInitScript(() => {
|
||||
(window as unknown as { __sentWsFrames: string[] }).__sentWsFrames = [];
|
||||
const origSend = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function (data) {
|
||||
try {
|
||||
(
|
||||
window as unknown as { __sentWsFrames: string[] }
|
||||
).__sentWsFrames.push(typeof data === "string" ? data : "(binary)");
|
||||
} catch {
|
||||
// ignore — best-effort tracing
|
||||
}
|
||||
return origSend.call(this, data);
|
||||
};
|
||||
});
|
||||
|
||||
await frigateApp.goto(`/#${PTZ_CAMERA}`);
|
||||
|
||||
// Locate the preset trigger — a button whose accessible name includes
|
||||
// "presets" (set via aria-label={t("ptz.presets")}).
|
||||
const presetTrigger = frigateApp.page.getByRole("button", {
|
||||
name: /presets/i,
|
||||
});
|
||||
await expect(presetTrigger.first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await presetTrigger.first().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Pick a preset.
|
||||
const firstPreset = menu
|
||||
.getByRole("menuitem", { name: PRESET_NAMES[0] })
|
||||
.first();
|
||||
await firstPreset.click();
|
||||
|
||||
// Menu closes.
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Preset command was dispatched over the WS.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const sentFrames = await frigateApp.page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __sentWsFrames: string[] })
|
||||
.__sentWsFrames,
|
||||
);
|
||||
|
||||
return sentFrames.some(
|
||||
(frame) =>
|
||||
frame.includes(`"${PTZ_CAMERA}/ptz"`) &&
|
||||
frame.includes(`preset_${PRESET_NAMES[0]}`),
|
||||
);
|
||||
},
|
||||
{ timeout: 2_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Body is interactive.
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
|
||||
// Presets tooltip should NOT be visible.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
frigateApp.page
|
||||
.locator('[role="tooltip"]')
|
||||
.filter({ hasText: /presets/i })
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
{ timeout: 1_000 },
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
// Now press keyboard keys — none should reopen the menu.
|
||||
await frigateApp.page.keyboard.press("ArrowUp");
|
||||
await frigateApp.page.keyboard.press("Space");
|
||||
await frigateApp.page.keyboard.press("Enter");
|
||||
await expect
|
||||
.poll(() => menu.isVisible().catch(() => false), { timeout: 1_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile live camera overlay @medium @mobile", () => {
|
||||
test("mobile single-camera view loads without freezing body", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (!frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Same config override as the desktop spec so the mobile page exercises
|
||||
// the onvif-enabled code path and its dismissable-layer consumers.
|
||||
await frigateApp.api.install({
|
||||
config: {
|
||||
cameras: {
|
||||
[PTZ_CAMERA]: {
|
||||
onvif: { host: "10.0.0.50" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
name: PTZ_CAMERA,
|
||||
features: ["pt", "zoom"],
|
||||
presets: PRESET_NAMES,
|
||||
profiles: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto(`/#${PTZ_CAMERA}`);
|
||||
|
||||
// Body must be interactive after navigation — this is the mobile-side
|
||||
// smoke test for the dismissable-layer dedupe. A regression that
|
||||
// stuck pointer-events: none on <body> would make the rest of the UI
|
||||
// unclickable.
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Radix overlay regression tests -- MEDIUM tier.
|
||||
*
|
||||
* Guards the bug class fixed by de-duping `@radix-ui/react-dismissable-layer`:
|
||||
*
|
||||
* 1. Body `pointer-events: none` getting stuck after nested overlays close
|
||||
* 2. Dropdown typeahead breaking on the second open
|
||||
* 3. Tooltips popping after a dropdown closes (focus restore side-effect)
|
||||
*
|
||||
* These tests are grouped by UI path rather than by symptom, since a given
|
||||
* flow usually exercises more than one failure mode.
|
||||
*
|
||||
* TODO: migrate these tests into the corresponding page specs
|
||||
* (face-library.spec.ts, system.spec.ts, review.spec.ts) when those files
|
||||
* come out of PENDING_REWRITE in e2e/scripts/lint-specs.mjs. They live in
|
||||
* a dedicated file today so they stay lint-compliant (no waitForTimeout,
|
||||
* no conditional isVisible) while the page specs are still exempt.
|
||||
*/
|
||||
|
||||
import { type Locator } from "@playwright/test";
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
|
||||
const GROUPED_FACE_EVENT_ID = "1775487131.3863528-abc123";
|
||||
const GROUPED_FACE_TRAINING_IMAGES = [
|
||||
`${GROUPED_FACE_EVENT_ID}-1775487131.3863528-unknown-0.95.webp`,
|
||||
`${GROUPED_FACE_EVENT_ID}-1775487132.3863528-unknown-0.91.webp`,
|
||||
];
|
||||
|
||||
async function installGroupedFaceAttemptData(app: FrigateApp) {
|
||||
await app.api.install({
|
||||
events: [
|
||||
{
|
||||
id: GROUPED_FACE_EVENT_ID,
|
||||
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",
|
||||
path_data: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
faces: {
|
||||
train: GROUPED_FACE_TRAINING_IMAGES,
|
||||
alice: ["alice-1.webp"],
|
||||
bob: ["bob-1.webp"],
|
||||
charlie: ["charlie-1.webp"],
|
||||
david: ["david-1.webp"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openGroupedFaceAttemptDialog(app: FrigateApp): Promise<Locator> {
|
||||
await installGroupedFaceAttemptData(app);
|
||||
await app.goto("/faces");
|
||||
|
||||
const groupedCardImage = app.page
|
||||
.locator('img[src*="clips/faces/train/"]')
|
||||
.first();
|
||||
const groupedCard = groupedCardImage.locator("xpath=..");
|
||||
await expect(groupedCardImage).toBeVisible({ timeout: 5_000 });
|
||||
await groupedCard.click();
|
||||
|
||||
const dialog = app.page
|
||||
.getByRole("dialog")
|
||||
.filter({ has: app.page.locator('img[src*="clips/faces/train/"]') })
|
||||
.first();
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog.locator('img[src*="clips/faces/train/"]')).toHaveCount(2);
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function groupedFaceReclassifyTriggers(dialog: Locator) {
|
||||
return dialog.locator('[aria-haspopup="menu"]');
|
||||
}
|
||||
|
||||
test.describe("FaceSelectionDialog @medium", () => {
|
||||
test("grouped recent-recognition dialog closes menu without re-popping tooltip or locking body", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = await openGroupedFaceAttemptDialog(frigateApp);
|
||||
const triggers = groupedFaceReclassifyTriggers(dialog);
|
||||
await expect(triggers).toHaveCount(2);
|
||||
|
||||
await triggers.first().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
|
||||
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The grouped recent-recognitions flow wraps the dropdown trigger in a
|
||||
// tooltip inside the detail dialog. Focus should not jump back there.
|
||||
const visibleTooltip = await frigateApp.page
|
||||
.locator('[role="tooltip"]')
|
||||
.filter({ hasText: /train face/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(
|
||||
visibleTooltip,
|
||||
"Train Face tooltip popped after dropdown closed in grouped dialog — focus-restore regression",
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("second grouped-image dropdown open accepts typeahead keyboard input", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = await openGroupedFaceAttemptDialog(frigateApp);
|
||||
const triggers = groupedFaceReclassifyTriggers(dialog);
|
||||
await expect(triggers).toHaveCount(2);
|
||||
|
||||
await triggers.first().click();
|
||||
let menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await triggers.nth(1).click();
|
||||
menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await frigateApp.page.keyboard.press("c");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
frigateApp.page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.textContent?.trim().toLowerCase() ?? "",
|
||||
),
|
||||
{ timeout: 2_000 },
|
||||
)
|
||||
.toMatch(/^charlie/);
|
||||
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("RestartDialog @medium", () => {
|
||||
test("cancelling restart leaves body interactive", async ({ frigateApp }) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await frigateApp.goto("/");
|
||||
|
||||
// "Restart Frigate" lives in the sidebar GeneralSettings dropdown. The
|
||||
// sidebar has several aria-haspopup triggers (System, Account, etc.);
|
||||
// we open each until the Restart item is visible.
|
||||
const sidebarTriggers = frigateApp.page
|
||||
.locator('[role="complementary"] [aria-haspopup="menu"]')
|
||||
.or(frigateApp.page.locator('aside [aria-haspopup="menu"]'));
|
||||
const triggerCount = await sidebarTriggers.count();
|
||||
expect(triggerCount).toBeGreaterThan(0);
|
||||
|
||||
let opened = false;
|
||||
for (let i = 0; i < triggerCount; i++) {
|
||||
const trigger = sidebarTriggers.nth(i);
|
||||
await trigger.click().catch(() => {});
|
||||
const restartItem = frigateApp.page
|
||||
.getByRole("menuitem", { name: /restart/i })
|
||||
.first();
|
||||
const isVisible = await expect(restartItem)
|
||||
.toBeVisible({ timeout: 300 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (isVisible) {
|
||||
await restartItem.click();
|
||||
opened = true;
|
||||
break;
|
||||
}
|
||||
await frigateApp.page.keyboard.press("Escape").catch(() => {});
|
||||
}
|
||||
|
||||
expect(opened).toBe(true);
|
||||
|
||||
const cancel = frigateApp.page.getByRole("button", { name: /cancel/i });
|
||||
await expect(cancel).toBeVisible({ timeout: 3_000 });
|
||||
await cancel.click();
|
||||
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
|
||||
// Sanity: the surrounding shell is still clickable after the dialog closes.
|
||||
const postCancelTrigger = sidebarTriggers.first();
|
||||
await postCancelTrigger.click();
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Nested overlay invariant @medium", () => {
|
||||
test("closing review filter popover leaves body interactive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await frigateApp.goto("/review");
|
||||
|
||||
const camerasBtn = frigateApp.page
|
||||
.getByRole("button", { name: /cameras/i })
|
||||
.first();
|
||||
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await camerasBtn.click();
|
||||
|
||||
const overlay = frigateApp.page
|
||||
.locator(
|
||||
'[role="menu"], [role="dialog"], [data-radix-popper-content-wrapper]',
|
||||
)
|
||||
.first();
|
||||
await expect(overlay).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(overlay).not.toBeVisible({ timeout: 3_000 });
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile face library overlay @medium @mobile", () => {
|
||||
test("mobile library selector dropdown closes cleanly", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (!frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// The library collection selector is a Radix DropdownMenu on both
|
||||
// desktop and mobile — a direct consumer of react-dismissable-layer.
|
||||
// This exercises the dedupe'd cleanup path on mobile viewport.
|
||||
await installGroupedFaceAttemptData(frigateApp);
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
const selector = frigateApp.page
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /\(\d+\)/ })
|
||||
.first();
|
||||
await expect(selector).toBeVisible({ timeout: 5_000 });
|
||||
await selector.click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible({ timeout: 3_000 });
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
});
|
||||
});
|
||||
@ -1,23 +1,304 @@
|
||||
/**
|
||||
* Replay page tests -- LOW tier.
|
||||
* Replay page tests -- MEDIUM tier.
|
||||
*
|
||||
* Tests replay page rendering and basic interactivity.
|
||||
* /replay is the admin debug replay page (not a recordings player).
|
||||
* Polls /api/debug_replay/status, renders a no-session state when
|
||||
* inactive, and a live camera image + debug toggles + Stop controls
|
||||
* when active.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import {
|
||||
activeSessionStatus,
|
||||
noSessionStatus,
|
||||
} from "../fixtures/mock-data/debug-replay";
|
||||
|
||||
test.describe("Replay Page @low", () => {
|
||||
test("replay page renders without crash", async ({ frigateApp }) => {
|
||||
async function installStatusRoute(
|
||||
app: { page: import("@playwright/test").Page },
|
||||
body: unknown,
|
||||
) {
|
||||
await app.page.route("**/api/debug_replay/status", (route) =>
|
||||
route.fulfill({ json: body }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Replay — no active session @medium", () => {
|
||||
test("empty state renders heading + Go to History button", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, noSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /No Active Replay Session/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
const goButton = frigateApp.page.getByRole("button", {
|
||||
name: /Go to History|Go to Recordings/i,
|
||||
});
|
||||
await expect(goButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("replay page has interactive controls", async ({ frigateApp }) => {
|
||||
test("clicking Go to History navigates to /review", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, noSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
const buttons = frigateApp.page.locator("button");
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /No Active Replay Session/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /Go to History|Go to Recordings/i })
|
||||
.click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Replay — active session @medium", () => {
|
||||
test("active status renders the Debug Replay side panel", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
// Three tabs (Debug / Objects / Messages) in TabsList
|
||||
await expect(frigateApp.page.locator('[role="tab"]')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("debug toggles render with bbox ON by default", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
const bbox = frigateApp.page.locator("#debug-bbox");
|
||||
await expect(bbox).toBeVisible({ timeout: 10_000 });
|
||||
await expect(bbox).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
test("clicking bbox toggle flips aria-checked", async ({ frigateApp }) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
const bbox = frigateApp.page.locator("#debug-bbox");
|
||||
await expect(bbox).toBeVisible({ timeout: 10_000 });
|
||||
await expect(bbox).toHaveAttribute("aria-checked", "true");
|
||||
await bbox.click();
|
||||
await expect(bbox).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
test("Configuration button opens the configuration dialog (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop: button has visible text label");
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// On desktop the span is visible and gives the button an accessible name.
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /configuration/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Configuration button opens the configuration dialog (mobile)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile: button is icon-only");
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// On mobile the Configuration button text span is hidden (md:inline).
|
||||
// It is the first button inside the right-side action group div
|
||||
// (the flex container that holds Config + Stop, sibling of the Back button).
|
||||
const actionGroup = frigateApp.page.locator(
|
||||
".flex.items-center.gap-2 button",
|
||||
);
|
||||
await actionGroup.first().click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Objects tab renders with the camera_activity objects list", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Send an activity payload with a person object on front_door.
|
||||
// Must be called after goto() so the WS connection is established.
|
||||
await frigateApp.ws.sendCameraActivity({
|
||||
front_door: {
|
||||
objects: [
|
||||
{
|
||||
label: "person",
|
||||
score: 0.95,
|
||||
box: [0.1, 0.1, 0.5, 0.8],
|
||||
area: 0.2,
|
||||
ratio: 0.6,
|
||||
region: [0.05, 0.05, 0.6, 0.85],
|
||||
current_zones: [],
|
||||
id: "obj-person-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Switch to Objects tab (labelled "Object List" in i18n).
|
||||
const objectsTab = frigateApp.page.getByRole("tab", {
|
||||
name: /object/i,
|
||||
});
|
||||
await objectsTab.click();
|
||||
await expect(objectsTab).toHaveAttribute("data-state", "active", {
|
||||
timeout: 3_000,
|
||||
});
|
||||
|
||||
// The object row renders the label.
|
||||
await expect(frigateApp.page.getByText(/person/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Messages tab renders WsMessageFeed container", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const messagesTab = frigateApp.page.getByRole("tab", {
|
||||
name: /messages/i,
|
||||
});
|
||||
await messagesTab.click();
|
||||
await expect(messagesTab).toHaveAttribute("data-state", "active", {
|
||||
timeout: 3_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("bbox info popover opens and closes cleanly", async ({ frigateApp }) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
// The bbox row has an info icon popover trigger next to its label.
|
||||
// The trigger is a div (not button) wrapping LuInfo with an sr-only
|
||||
// "Info" span. Target it by the sr-only text content.
|
||||
const infoTrigger = frigateApp.page
|
||||
.locator("span.sr-only", { hasText: /info/i })
|
||||
.first();
|
||||
await expect(infoTrigger).toBeVisible({ timeout: 10_000 });
|
||||
// Click the parent div (the actual trigger)
|
||||
await infoTrigger.locator("..").click();
|
||||
|
||||
const popover = frigateApp.page.locator(
|
||||
"[data-radix-popper-content-wrapper]",
|
||||
);
|
||||
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(popover.first()).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Replay — stop flow (desktop) @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Desktop button has accessible 'Stop Replay' name",
|
||||
);
|
||||
|
||||
test("Stop Replay opens confirm dialog; confirm POSTs debug_replay/stop", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
let stopCalled = false;
|
||||
await frigateApp.page.route("**/api/debug_replay/stop", async (route) => {
|
||||
if (route.request().method() === "POST") stopCalled = true;
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
await frigateApp.goto("/replay");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /stop replay/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 3_000 });
|
||||
await dialog
|
||||
.getByRole("button", { name: /stop|confirm/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect.poll(() => stopCalled, { timeout: 5_000 }).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Replay — stop button (mobile) @medium @mobile", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => !frigateApp.isMobile,
|
||||
"Mobile-only icon-button variant",
|
||||
);
|
||||
|
||||
test("tapping the icon-only stop button opens the confirm dialog", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, activeSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// On mobile the Stop button is an icon (LuSquare) inside an
|
||||
// AlertDialogTrigger. It's the last button in the top bar's
|
||||
// right-side action group (Back is on the left). Target by
|
||||
// position within the top-bar flex container.
|
||||
const topRightButtons = frigateApp.page
|
||||
.locator(".min-h-12 button, .md\\:min-h-16 button")
|
||||
.filter({ hasNot: frigateApp.page.getByLabel("Back") });
|
||||
const lastButton = topRightButtons.last();
|
||||
await expect(lastButton).toBeVisible({ timeout: 10_000 });
|
||||
await lastButton.click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 3_000 });
|
||||
await dialog.getByRole("button", { name: /cancel/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Replay — mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("no-session state renders at mobile viewport", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installStatusRoute(frigateApp, noSessionStatus());
|
||||
await frigateApp.goto("/replay");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /No Active Replay Session/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,200 +1,230 @@
|
||||
/**
|
||||
* 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.
|
||||
* Severity tabs, filter popovers, calendar, show-reviewed toggle,
|
||||
* timeline, and the nested-overlay regression migrated from
|
||||
* radix-overlay-regressions.spec.ts.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { BasePage } from "../pages/base.page";
|
||||
import { ReviewPage } from "../pages/review.page";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
|
||||
test.describe("Review Page - Severity Tabs @critical", () => {
|
||||
test("severity tabs render with Alerts, Detections, Motion", async ({
|
||||
test.describe("Review — severity tabs @critical", () => {
|
||||
test("tabs render with Alerts default-on", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(review.detectionsTab).toBeVisible();
|
||||
await expect(review.motionTab).toBeVisible();
|
||||
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||
});
|
||||
|
||||
test("clicking Detections flips data-state", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||
await review.detectionsTab.click();
|
||||
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
|
||||
});
|
||||
|
||||
test("clicking Motion flips data-state", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||
await review.motionTab.click();
|
||||
await expect(review.motionTab).toHaveAttribute("data-state", "on");
|
||||
});
|
||||
|
||||
test("switching back to Alerts works", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||
await review.detectionsTab.click();
|
||||
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||
await review.alertsTab.click();
|
||||
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||
});
|
||||
|
||||
test("switching tabs updates active data-state (client-side filter)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The severity tabs filter the already-fetched review data client-side;
|
||||
// they do not trigger a new /api/review network request. This test
|
||||
// verifies the state-change assertion that the tab switch takes effect.
|
||||
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",
|
||||
);
|
||||
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||
await review.detectionsTab.click();
|
||||
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Review Page - Filters @critical", () => {
|
||||
test("All Cameras filter button opens popover with camera names", async ({
|
||||
test.describe("Review — filters (desktop) @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Filter bar differs on mobile",
|
||||
);
|
||||
|
||||
test("Cameras popover lists configured 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
|
||||
const review = new ReviewPage(frigateApp.page, true);
|
||||
await expect(review.camerasFilterTrigger).toBeVisible({ timeout: 5_000 });
|
||||
await review.camerasFilterTrigger.click();
|
||||
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
|
||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
||||
});
|
||||
|
||||
test("closing the Cameras popover with Escape leaves body interactive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Migrated from radix-overlay-regressions.spec.ts.
|
||||
await frigateApp.goto("/review");
|
||||
const review = new ReviewPage(frigateApp.page, true);
|
||||
await review.camerasFilterTrigger.click();
|
||||
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
await expect(review.filterOverlay).not.toBeVisible({ timeout: 3_000 });
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
});
|
||||
|
||||
test("Labels are shown inside the General Filter dialog", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Labels are surfaced inside the "Filter" button's GeneralFilterContent
|
||||
// dialog, not as a standalone top-level button. We open that dialog and
|
||||
// confirm labels from the camera config are listed there.
|
||||
await frigateApp.goto("/review");
|
||||
const filterBtn = frigateApp.page
|
||||
.getByRole("button", { name: /^filter$/i })
|
||||
.first();
|
||||
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
|
||||
await filterBtn.click();
|
||||
|
||||
const overlay = frigateApp.page.locator(
|
||||
"[data-radix-popper-content-wrapper], [role='dialog']",
|
||||
);
|
||||
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||
// The default mock config for front_door tracks "person"
|
||||
await expect(overlay.first().getByText(/person/i)).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,
|
||||
test("Zones popover lists configured zones inside the General Filter dialog", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Override config to guarantee a known zone on front_door.
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
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();
|
||||
}
|
||||
await frigateApp.goto("/review");
|
||||
const filterBtn = frigateApp.page
|
||||
.getByRole("button", { name: /^filter$/i })
|
||||
.first();
|
||||
await expect(filterBtn).toBeVisible({ timeout: 5_000 });
|
||||
await filterBtn.click();
|
||||
|
||||
const overlay = frigateApp.page.locator(
|
||||
"[data-radix-popper-content-wrapper], [role='dialog']",
|
||||
);
|
||||
await expect(overlay.first()).toBeVisible({ timeout: 3_000 });
|
||||
await expect(overlay.first().getByText(/front.?yard/i)).toBeVisible();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Last 24 Hours calendar button opens date picker", async ({
|
||||
test("Calendar trigger opens a date picker popover", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/review");
|
||||
await frigateApp.page.waitForTimeout(1000);
|
||||
const review = new ReviewPage(frigateApp.page, true);
|
||||
await expect(review.calendarTrigger).toBeVisible({ timeout: 5_000 });
|
||||
await review.calendarTrigger.click();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
// react-day-picker v9 renders a role="grid" calendar with day cells
|
||||
// as buttons inside gridcells (e.g. "Wednesday, April 1st, 2026").
|
||||
// The calendar is placed directly in the DOM (not always inside a
|
||||
// Radix popper wrapper), so scope by the grid role instead.
|
||||
const calendarGrid = frigateApp.page.locator('[role="grid"]').first();
|
||||
await expect(calendarGrid).toBeVisible({ timeout: 3_000 });
|
||||
const dayButton = calendarGrid.locator('[role="gridcell"] button').first();
|
||||
await expect(dayButton).toBeVisible({ timeout: 3_000 });
|
||||
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 ({
|
||||
test("Show Reviewed switch flips its checked state", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// "Show Reviewed" is a Radix Switch (role=switch), not a button.
|
||||
// It filters review data client-side; it does not trigger a new
|
||||
// /api/review network request. Verify the switch state toggles.
|
||||
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/);
|
||||
const showReviewedSwitch = frigateApp.page.getByRole("switch", {
|
||||
name: /show reviewed/i,
|
||||
});
|
||||
await expect(showReviewedSwitch).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Record initial checked state and click to toggle
|
||||
const initialChecked =
|
||||
await showReviewedSwitch.getAttribute("aria-checked");
|
||||
await showReviewedSwitch.click();
|
||||
const flippedChecked = initialChecked === "true" ? "false" : "true";
|
||||
await expect(showReviewedSwitch).toHaveAttribute(
|
||||
"aria-checked",
|
||||
flippedChecked,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Review Page - Navigation @critical", () => {
|
||||
test("navigate to review from live page works", async ({ frigateApp }) => {
|
||||
test.describe("Review — timeline (desktop) @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Timeline not shown on mobile",
|
||||
);
|
||||
|
||||
test("timeline renders time markers", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await frigateApp.page.textContent("#pageRoot")) ?? "",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toMatch(/[AP]M|\d+:\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Review — mobile @critical @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("severity tabs render on mobile", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/review");
|
||||
const review = new ReviewPage(frigateApp.page, false);
|
||||
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(review.detectionsTab).toBeVisible();
|
||||
});
|
||||
|
||||
test("back navigation returns to Live", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/");
|
||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
||||
const base = new BasePage(frigateApp.page, false);
|
||||
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,
|
||||
});
|
||||
await base.navigateTo("/");
|
||||
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,15 +1,21 @@
|
||||
/**
|
||||
* System page tests -- MEDIUM tier.
|
||||
* System page tests -- MEDIUM tier (promoted to cover migrated
|
||||
* RestartDialog test from radix-overlay-regressions.spec.ts).
|
||||
*
|
||||
* Tests system page rendering with tabs and tab switching.
|
||||
* Navigates to /system#general explicitly so useHashState resolves
|
||||
* the tab state deterministically.
|
||||
* Tab switching, version + last-refreshed display, and the
|
||||
* RestartDialog cancel flow.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
} from "../helpers/overlay-interaction";
|
||||
|
||||
test.describe("System Page @medium", () => {
|
||||
test("system page renders with tab buttons", async ({ frigateApp }) => {
|
||||
test.describe("System — tabs @medium", () => {
|
||||
test("general tab is active by default via #general hash", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
@ -20,7 +26,7 @@ test.describe("System Page @medium", () => {
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
||||
});
|
||||
|
||||
test("general tab is active when navigated via hash", async ({
|
||||
test("Storage tab activates and deactivates General", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
@ -29,18 +35,6 @@ test.describe("System Page @medium", () => {
|
||||
"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",
|
||||
@ -53,29 +47,22 @@ test.describe("System Page @medium", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("clicking Cameras tab activates it and deactivates General", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test("Cameras tab activates", 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 ({
|
||||
test("general tab shows version and last-refreshed", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
@ -87,4 +74,164 @@ test.describe("System Page @medium", () => {
|
||||
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("storage tab renders content after switching", 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 },
|
||||
);
|
||||
// On desktop, tab buttons render text labels so the word "storage"
|
||||
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||
// icon-only, so we verify the general-tab content disappears instead
|
||||
// (the storage tab's metrics section is hidden but general is gone).
|
||||
if (!frigateApp.isMobile) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await frigateApp.page.textContent("#pageRoot")) ?? "",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toMatch(/storage|mount|disk|used|free/i);
|
||||
} else {
|
||||
// Mobile: tab activation (data-state "on") already asserted above.
|
||||
// Additionally confirm general tab is no longer the active tab.
|
||||
await expect(
|
||||
frigateApp.page.getByLabel("Select general"),
|
||||
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("cameras tab renders each configured camera", 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 },
|
||||
);
|
||||
// Cameras tab lists every camera from config/stats. The default
|
||||
// mock has front_door, backyard, garage.
|
||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByText(new RegExp(cam.replace("_", ".?"), "i"))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("enrichments tab renders when semantic search is enabled", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Override config to guarantee the enrichments tab is present.
|
||||
// System.tsx shows the tab when semantic_search.enabled === true.
|
||||
await frigateApp.installDefaults({
|
||||
config: { semantic_search: { enabled: true } },
|
||||
});
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
||||
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
||||
await enrichTab.click();
|
||||
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("System — RestartDialog @medium", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Sidebar menu is desktop-only",
|
||||
);
|
||||
|
||||
test("cancelling restart leaves body interactive", async ({ frigateApp }) => {
|
||||
// Migrated from radix-overlay-regressions.spec.ts.
|
||||
await frigateApp.goto("/");
|
||||
|
||||
const sidebarTriggers = frigateApp.page
|
||||
.locator('[role="complementary"] [aria-haspopup="menu"]')
|
||||
.or(frigateApp.page.locator('aside [aria-haspopup="menu"]'));
|
||||
const triggerCount = await sidebarTriggers.count();
|
||||
expect(triggerCount).toBeGreaterThan(0);
|
||||
|
||||
let opened = false;
|
||||
for (let i = 0; i < triggerCount; i++) {
|
||||
const trigger = sidebarTriggers.nth(i);
|
||||
await trigger.click().catch(() => {});
|
||||
const restartItem = frigateApp.page
|
||||
.getByRole("menuitem", { name: /restart/i })
|
||||
.first();
|
||||
const visible = await expect(restartItem)
|
||||
.toBeVisible({ timeout: 300 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (visible) {
|
||||
await restartItem.click();
|
||||
opened = true;
|
||||
break;
|
||||
}
|
||||
await frigateApp.page.keyboard.press("Escape").catch(() => {});
|
||||
}
|
||||
expect(opened).toBe(true);
|
||||
|
||||
const cancel = frigateApp.page.getByRole("button", { name: /cancel/i });
|
||||
await expect(cancel).toBeVisible({ timeout: 3_000 });
|
||||
await cancel.click();
|
||||
|
||||
await waitForBodyInteractive(frigateApp.page);
|
||||
await expectBodyInteractive(frigateApp.page);
|
||||
|
||||
const postCancelTrigger = sidebarTriggers.first();
|
||||
await postCancelTrigger.click();
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("System — mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switching tabs works at mobile viewport", 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
8
web/package-lock.json
generated
8
web/package-lock.json
generated
@ -54,7 +54,7 @@
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"konva": "^10.2.3",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"next-themes": "^0.4.6",
|
||||
@ -9636,9 +9636,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"konva": "^10.2.3",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@ -415,6 +415,7 @@
|
||||
"audioCodecGood": "Audio codec is {{codec}}.",
|
||||
"resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.",
|
||||
"resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.",
|
||||
"resolutionUnknown": "The resolution of this stream could not be probed. This will cause issues on startup. You should manually set the detect resolution in Settings or your config.",
|
||||
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
|
||||
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
|
||||
"audioCodecRequired": "An audio stream is required to support audio detection.",
|
||||
|
||||
@ -218,7 +218,7 @@ export default function CameraReviewClassification({
|
||||
<Label
|
||||
className={cn(
|
||||
"flex flex-row items-center text-base",
|
||||
alertsZonesModified && "text-danger",
|
||||
alertsZonesModified && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
||||
@ -286,7 +286,7 @@ export default function CameraReviewClassification({
|
||||
<Label
|
||||
className={cn(
|
||||
"flex flex-row items-center text-base",
|
||||
detectionsZonesModified && "text-danger",
|
||||
detectionsZonesModified && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
<Trans ns="views/settings">
|
||||
|
||||
@ -1012,7 +1012,7 @@ export function ConfigSection({
|
||||
>
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "You have unsaved changes",
|
||||
@ -1299,7 +1299,7 @@ export function ConfigSection({
|
||||
{hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
||||
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||
>
|
||||
{t("button.modified", {
|
||||
ns: "common",
|
||||
|
||||
@ -154,7 +154,7 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn("text-sm", isModified && "text-danger")}
|
||||
className={cn("text-sm", isModified && "text-unsaved")}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
@ -142,7 +142,7 @@ export function ReplaceRulesField(props: FieldProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn("text-sm", isModified && "text-danger")}
|
||||
className={cn("text-sm", isModified && "text-unsaved")}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
@ -497,7 +497,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
isModified && "text-unsaved",
|
||||
hasFieldErrors && "text-destructive",
|
||||
)}
|
||||
>
|
||||
@ -516,7 +516,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
return (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn("text-sm font-medium", isModified && "text-danger")}
|
||||
className={cn("text-sm font-medium", isModified && "text-unsaved")}
|
||||
>
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
@ -535,7 +535,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
isModified && "text-unsaved",
|
||||
hasFieldErrors && "text-destructive",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -467,7 +467,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
hasModifiedDescendants && "text-danger",
|
||||
hasModifiedDescendants && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
{inferredLabel}
|
||||
|
||||
@ -607,23 +607,38 @@ function StreamIssues({
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.roles.includes("detect") && stream.resolution) {
|
||||
const [width, height] = stream.resolution.split("x").map(Number);
|
||||
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
|
||||
const minDimension = Math.min(width, height);
|
||||
const maxDimension = Math.max(width, height);
|
||||
if (stream.roles.includes("detect") && stream.testResult) {
|
||||
const probedResolution = stream.testResult.resolution;
|
||||
let probedWidth = 0;
|
||||
let probedHeight = 0;
|
||||
if (probedResolution) {
|
||||
const [w, h] = probedResolution.split("x").map(Number);
|
||||
if (!isNaN(w) && !isNaN(h)) {
|
||||
probedWidth = w;
|
||||
probedHeight = h;
|
||||
}
|
||||
}
|
||||
|
||||
if (probedWidth <= 0 || probedHeight <= 0) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.resolutionUnknown"),
|
||||
});
|
||||
} else {
|
||||
const minDimension = Math.min(probedWidth, probedHeight);
|
||||
const maxDimension = Math.max(probedWidth, probedHeight);
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
resolution: stream.resolution,
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
resolution: stream.resolution,
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -1435,7 +1435,7 @@ export default function Settings() {
|
||||
/>
|
||||
)}
|
||||
{showUnsavedDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-danger" />
|
||||
<span className="inline-block size-2 rounded-full bg-unsaved" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -1516,7 +1516,7 @@ export default function Settings() {
|
||||
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "You have unsaved changes",
|
||||
|
||||
@ -79,11 +79,11 @@ const PROFILE_COLORS: ProfileColor[] = [
|
||||
bgMuted: "bg-green-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-amber-400",
|
||||
text: "text-amber-400",
|
||||
dot: "bg-amber-400",
|
||||
border: "border-amber-400",
|
||||
bgMuted: "bg-amber-400/20",
|
||||
bg: "bg-fuchsia-500",
|
||||
text: "text-fuchsia-500",
|
||||
dot: "bg-fuchsia-500",
|
||||
border: "border-fuchsia-500",
|
||||
bgMuted: "bg-fuchsia-500/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-slate-400",
|
||||
@ -93,11 +93,11 @@ const PROFILE_COLORS: ProfileColor[] = [
|
||||
bgMuted: "bg-slate-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-orange-300",
|
||||
text: "text-orange-300",
|
||||
dot: "bg-orange-300",
|
||||
border: "border-orange-300",
|
||||
bgMuted: "bg-orange-300/20",
|
||||
bg: "bg-stone-500",
|
||||
text: "text-stone-500",
|
||||
dot: "bg-stone-500",
|
||||
border: "border-stone-500",
|
||||
bgMuted: "bg-stone-500/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-blue-300",
|
||||
|
||||
@ -380,7 +380,9 @@ export default function Go2RtcStreamsSettingsView({
|
||||
>
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">{t("unsavedChanges")}</span>
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
|
||||
@ -212,7 +212,7 @@ export function SingleSectionPage({
|
||||
{sectionStatus.hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
||||
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||
>
|
||||
{t("button.modified", {
|
||||
ns: "common",
|
||||
@ -250,7 +250,7 @@ export function SingleSectionPage({
|
||||
{sectionStatus.hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
||||
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||
>
|
||||
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
|
||||
</Badge>
|
||||
|
||||
@ -65,6 +65,7 @@ module.exports = {
|
||||
ring: "hsl(var(--ring))",
|
||||
danger: "#ef4444",
|
||||
success: "#22c55e",
|
||||
unsaved: "#f59e0b",
|
||||
background: "hsl(var(--background))",
|
||||
background_alt: "hsl(var(--background-alt))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user