mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-15 00:11:15 +03:00
updated specs
This commit is contained in:
parent
3a4302a764
commit
5ca0ce02e0
@ -1,147 +1,121 @@
|
|||||||
/**
|
/**
|
||||||
* Auth and cross-cutting tests -- HIGH tier.
|
* Auth and role tests -- HIGH tier.
|
||||||
*
|
*
|
||||||
* Tests protected route access for admin/viewer roles,
|
* Admin access to /system, /config, /logs; viewer access denied
|
||||||
* access denied page rendering, viewer nav restrictions,
|
* markers (via i18n heading, not a data-testid we don't own);
|
||||||
* and all routes smoke test.
|
* viewer nav restrictions; all-routes smoke.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Auth - Admin Access @high", () => {
|
const ADMIN_ROUTES = [
|
||||||
test("admin can access /system and sees system tabs", async ({
|
{ path: "/system", marker: () => ({ label: "Select general" }) },
|
||||||
frigateApp,
|
{
|
||||||
}) => {
|
path: "/logs",
|
||||||
|
marker: () => ({ label: "Select frigate" }),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.describe("Auth — admin access @high", () => {
|
||||||
|
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system");
|
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({
|
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /config and Monaco editor loads", async ({
|
test("admin /config renders Monaco editor", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/config");
|
await frigateApp.goto("/config");
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
await expect(
|
||||||
const editor = frigateApp.page.locator(
|
frigateApp.page
|
||||||
".monaco-editor, [data-keybinding-context]",
|
.locator(".monaco-editor, [data-keybinding-context]")
|
||||||
);
|
.first(),
|
||||||
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /logs and sees service tabs", async ({
|
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/logs");
|
await frigateApp.goto("/logs");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
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.describe("Auth — viewer restrictions @high", () => {
|
||||||
test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => {
|
for (const path of ["/system", "/config", "/logs"]) {
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
test(`viewer on ${path} sees AccessDenied`, async ({ frigateApp }) => {
|
||||||
await page.goto("/system");
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
await page.waitForTimeout(2000);
|
await frigateApp.page.goto(path);
|
||||||
// Should show "Access Denied" text
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
await expect(page.getByText("Access Denied")).toBeVisible({
|
await expect(
|
||||||
timeout: 5_000,
|
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 frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
await page.goto("/config");
|
await frigateApp.page.goto("/review");
|
||||||
await page.waitForTimeout(2000);
|
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
||||||
await expect(page.getByText("Access Denied")).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("viewer sees Access Denied on /logs", async ({ frigateApp, page }) => {
|
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
|
||||||
await page.goto("/logs");
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await expect(page.getByText("Access Denied")).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("viewer can access Live page and sees cameras", async ({
|
|
||||||
frigateApp,
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.installDefaults({ profile: viewerProfile() });
|
|
||||||
await page.goto("/");
|
|
||||||
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(page.locator("[data-camera='front_door']")).toBeVisible({
|
|
||||||
timeout: 10_000,
|
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,
|
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() });
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
await page.goto(route);
|
await frigateApp.page.goto(route);
|
||||||
await page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
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.describe("Auth — viewer nav restrictions (desktop) @high", () => {
|
||||||
test("all user routes render without crash", async ({ frigateApp }) => {
|
test.skip(
|
||||||
const routes = ["/", "/review", "/explore", "/export", "/settings"];
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
for (const route of routes) {
|
"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 frigateApp.goto(route);
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
timeout: 10_000,
|
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,323 @@
|
|||||||
/**
|
/**
|
||||||
* Chat page tests -- MEDIUM tier.
|
* 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 }) => {
|
* Join NDJSON chunks into a single body (for non-streaming tests).
|
||||||
await frigateApp.goto("/chat");
|
*/
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
function ndjsonBody(chunks: Array<Record<string, unknown>>): string {
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
return chunks.map((c) => JSON.stringify(c)).join("\n") + "\n";
|
||||||
});
|
}
|
||||||
|
|
||||||
test("chat page has interactive input or buttons", async ({ frigateApp }) => {
|
/**
|
||||||
await frigateApp.goto("/chat");
|
* Install a window.fetch override on the page so that POSTs to
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
* chat/completion resolve with a real ReadableStream that emits the
|
||||||
const interactive = frigateApp.page.locator("input, textarea, button");
|
* given chunks over time. This is the only way to validate
|
||||||
const count = await interactive.count();
|
* chunk-by-chunk rendering through Playwright — page.route() does not
|
||||||
expect(count).toBeGreaterThan(0);
|
* 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 input accepts text", 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.goto("/chat");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
const input = frigateApp.page.locator("input, textarea").first();
|
frigateApp.page.getByRole("heading", { level: 1 }),
|
||||||
if (await input.isVisible().catch(() => false)) {
|
).toBeVisible({ timeout: 10_000 });
|
||||||
await input.fill("What cameras detected a person today?");
|
await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible();
|
||||||
const value = await input.inputValue();
|
// Four quick-reply buttons from starting_requests.*
|
||||||
expect(value.length).toBeGreaterThan(0);
|
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,238 @@
|
|||||||
/**
|
/**
|
||||||
* Classification page tests -- MEDIUM tier.
|
* 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 { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Classification @medium", () => {
|
const CUSTOM_MODELS = {
|
||||||
test("classification page renders without crash", async ({ frigateApp }) => {
|
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 frigateApp.goto("/classification");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
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 ({
|
test("empty custom map renders without crash", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
await frigateApp.installDefaults({
|
||||||
}) => {
|
config: { classification: { custom: {} } },
|
||||||
|
});
|
||||||
await frigateApp.goto("/classification");
|
await frigateApp.goto("/classification");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
timeout: 10_000,
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("classification page has interactive elements", async ({
|
test("toggling to states view switches the rendered card set", async ({
|
||||||
frigateApp,
|
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.goto("/classification");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
// Objects is default — object_classifier visible, state_classifier hidden.
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
await expect(
|
||||||
const count = await buttons.count();
|
frigateApp.page.getByText("object_classifier"),
|
||||||
expect(count).toBeGreaterThanOrEqual(0);
|
).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,283 @@
|
|||||||
/**
|
/**
|
||||||
* Config Editor page tests -- MEDIUM tier.
|
* Config Editor tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests Monaco editor loading, YAML content rendering,
|
* Monaco load + value, Save (config/save?save_option=saveonly),
|
||||||
* save button presence, and copy button interaction.
|
* Save error path, Save and Restart (WS frame via useRestart),
|
||||||
|
* Copy (clipboard), schema markers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
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", () => {
|
const SAMPLE_CONFIG = "mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n";
|
||||||
test("config editor loads Monaco editor with content", async ({
|
|
||||||
|
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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
await frigateApp.goto("/config");
|
await frigateApp.goto("/config");
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
await expect(
|
||||||
// Monaco editor should render with a specific class
|
frigateApp.page.locator(".monaco-editor").first(),
|
||||||
const editor = frigateApp.page.locator(
|
).toBeVisible({ timeout: 15_000 });
|
||||||
".monaco-editor, [data-keybinding-context]",
|
// Assert via DOM-rendered visible text (Monaco virtualizes — works
|
||||||
);
|
// for short configs which covers our mocked content).
|
||||||
await expect(editor.first()).toBeVisible({ timeout: 10_000 });
|
await expect
|
||||||
});
|
.poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 })
|
||||||
|
.toContain("front_door");
|
||||||
test("config editor has action buttons", async ({ frigateApp }) => {
|
});
|
||||||
await frigateApp.goto("/config");
|
});
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
|
||||||
const buttons = frigateApp.page.locator("button");
|
test.describe("Config Editor — Save @medium", () => {
|
||||||
const count = await buttons.count();
|
test.skip(
|
||||||
expect(count).toBeGreaterThan(0);
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
});
|
"Save button copy is desktop-visible (hidden md:block)",
|
||||||
|
);
|
||||||
test("config editor button clicks do not crash", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/config");
|
test("clicking Save Only POSTs config/save?save_option=saveonly", async ({
|
||||||
await frigateApp.page.waitForTimeout(5000);
|
frigateApp,
|
||||||
// Find buttons with SVG icons (copy, save, etc.)
|
}) => {
|
||||||
const iconButtons = frigateApp.page.locator("button:has(svg)");
|
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
|
||||||
const count = await iconButtons.count();
|
const capture = await installSaveRoute(frigateApp, 200, {
|
||||||
if (count > 0) {
|
message: "Config saved",
|
||||||
// Click the first icon button (likely copy)
|
});
|
||||||
await iconButtons.first().click();
|
await frigateApp.goto("/config");
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await expect(
|
||||||
}
|
frigateApp.page.locator(".monaco-editor").first(),
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
).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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,97 +1,269 @@
|
|||||||
/**
|
/**
|
||||||
* Explore page tests -- HIGH tier.
|
* Explore page tests -- HIGH tier.
|
||||||
*
|
*
|
||||||
* Tests search input with text entry and clearing, camera filter popover
|
* Search input, Enter submission, camera filter popover (desktop),
|
||||||
* opening with camera names, and content rendering with mock events.
|
* 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";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
|
||||||
test.describe("Explore Page - Search @high", () => {
|
// Semantic search config override used by multiple tests. Using model:
|
||||||
test("explore page renders with filter buttons", async ({ frigateApp }) => {
|
// "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");
|
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();
|
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 ({
|
// Filter bar — desktop only
|
||||||
frigateApp,
|
// Filter buttons appear once hasExistingSearch is true (URL params present).
|
||||||
}) => {
|
// ---------------------------------------------------------------------------
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
test.describe("Explore — filters (desktop) @high", () => {
|
||||||
return;
|
test.skip(
|
||||||
}
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
await frigateApp.goto("/explore");
|
"Desktop popovers",
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
);
|
||||||
const camerasBtn = frigateApp.page.getByRole("button", {
|
|
||||||
name: /cameras/i,
|
test("Cameras popover lists configured cameras", async ({ frigateApp }) => {
|
||||||
});
|
// Navigate with a labels filter param so the filter bar renders.
|
||||||
if (await camerasBtn.isVisible().catch(() => false)) {
|
await frigateApp.goto("/explore?labels=person");
|
||||||
await camerasBtn.click();
|
// CamerasFilterButton has aria-label="Cameras Filter". Use getByLabel to
|
||||||
await frigateApp.page.waitForTimeout(500);
|
// match against the accessible name (not the inner "All Cameras" text).
|
||||||
const popover = frigateApp.page.locator(
|
const camerasBtn = frigateApp.page.getByLabel("Cameras Filter").first();
|
||||||
"[data-radix-popper-content-wrapper]",
|
await expect(camerasBtn).toBeVisible({ timeout: 10_000 });
|
||||||
);
|
await camerasBtn.click();
|
||||||
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
// DropdownMenu on desktop wraps content in data-radix-popper-content-wrapper.
|
||||||
// Camera names from config should be in the popover
|
const popover = frigateApp.page.locator(
|
||||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
"[data-radix-popper-content-wrapper]",
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
);
|
||||||
}
|
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 ({
|
test("Labels filter lists labels from config", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
// Navigate with an existing search so the filter bar renders.
|
||||||
}) => {
|
await frigateApp.goto("/explore?labels=person");
|
||||||
await frigateApp.goto("/explore");
|
// GeneralFilterButton has aria-label="Labels". With ?labels=person the
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
// button text shows "Person" (the selected label), but the aria-label
|
||||||
const firstButton = frigateApp.page.locator("#pageRoot button").first();
|
// remains "Labels".
|
||||||
await expect(firstButton).toBeVisible({ timeout: 5_000 });
|
const labelsBtn = frigateApp.page.getByLabel("Labels").first();
|
||||||
await firstButton.click();
|
await expect(labelsBtn).toBeVisible({ timeout: 10_000 });
|
||||||
await frigateApp.page.waitForTimeout(500);
|
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.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,
|
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.goto("/explore");
|
||||||
await frigateApp.page.waitForTimeout(3000);
|
const searchInput = frigateApp.page.locator("input").first();
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
expect(pageText?.length).toBeGreaterThan(0);
|
|
||||||
|
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,393 @@
|
|||||||
/**
|
/**
|
||||||
* 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", () => {
|
const GROUPED_EVENT_ID = "1775487131.3863528-abc123";
|
||||||
test("face library page renders without crash", async ({ frigateApp }) => {
|
|
||||||
|
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 frigateApp.goto("/faces");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
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 ({
|
test("tiles render for each named collection", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
await frigateApp.installDefaults({ faces: basicFacesMock() });
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/faces");
|
await frigateApp.goto("/faces");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
// Open the dropdown — collections list shows "alice (2)" and "bob (1)".
|
||||||
// With empty faces mock, should show empty state or content
|
const menu = await openLibraryDropdown(frigateApp);
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
await expect(
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
menu.locator('[role="menuitem"]').filter({ hasText: /alice/i }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(
|
||||||
test("face library has interactive buttons", async ({ frigateApp }) => {
|
menu.locator('[role="menuitem"]').filter({ hasText: /bob/i }).first(),
|
||||||
await frigateApp.goto("/faces");
|
).toBeVisible();
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
});
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
});
|
||||||
const count = await buttons.count();
|
|
||||||
expect(count).toBeGreaterThanOrEqual(0);
|
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,50 @@
|
|||||||
/**
|
/**
|
||||||
* Live page tests -- CRITICAL tier.
|
* Live page tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests camera dashboard rendering, camera card clicks, single camera view
|
* Dashboard grid, single-camera controls, feature toggles (with WS
|
||||||
* with named controls, feature toggle behavior, context menu, and mobile layout.
|
* 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 { 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.describe("Live Dashboard @critical", () => {
|
||||||
test("dashboard renders all configured cameras by name", async ({
|
test("every configured camera renders on the dashboard", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
|
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||||
await expect(
|
await expect(live.cameraCard(cam)).toBeVisible({ timeout: 10_000 });
|
||||||
frigateApp.page.locator(`[data-camera='${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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
const live = new LivePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
await card.click({ timeout: 10_000 });
|
await live.cameraCard("front_door").first().click({ timeout: 10_000 });
|
||||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back button returns from single camera to dashboard", async ({
|
test("birdseye route renders without crash", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// First navigate to dashboard so there's history to go back to
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
// Click a camera to enter single view
|
|
||||||
const card = frigateApp.page.locator("[data-camera='front_door']").first();
|
|
||||||
await card.click({ timeout: 10_000 });
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// Now click Back to return to dashboard
|
|
||||||
const backBtn = frigateApp.page.getByText("Back", { exact: true });
|
|
||||||
if (await backBtn.isVisible().catch(() => false)) {
|
|
||||||
await backBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
// Should be back on the dashboard with cameras visible
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator("[data-camera='front_door']"),
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("birdseye view loads without crash", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/#birdseye");
|
await frigateApp.goto("/#birdseye");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||||
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("empty group shows fallback content", async ({ frigateApp }) => {
|
test("empty group shows fallback content", async ({ frigateApp }) => {
|
||||||
@ -63,191 +54,236 @@ test.describe("Live Dashboard @critical", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Live Single Camera - Controls @critical", () => {
|
test.describe("Live Single Camera — desktop controls @critical", () => {
|
||||||
test("single camera view shows Back and History buttons (desktop)", async ({
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Desktop-only header controls",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("single-camera view shows Back and History buttons", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip(); // On mobile, buttons may show icons only
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Back and History are visible text buttons in the header
|
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||||
await expect(
|
await expect(live.historyButton).toBeVisible();
|
||||||
frigateApp.page.getByText("Back", { exact: true }),
|
|
||||||
).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("History", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single camera view shows feature toggle icons (desktop)", async ({
|
test("feature toggles render (at least 3)", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Feature toggles are CameraFeatureToggle components rendered as divs
|
// Wait for the single-camera header to render before counting toggles.
|
||||||
// with bg-selected (active) or bg-secondary (inactive) classes
|
await expect(live.backButton).toBeVisible({ timeout: 5_000 });
|
||||||
// Count the toggles - should have at least detect, recording, snapshots
|
await expect(live.featureToggles.first()).toBeVisible({ timeout: 5_000 });
|
||||||
const toggles = frigateApp.page.locator(
|
const count = await live.featureToggles.count();
|
||||||
".flex.flex-col.items-center.justify-center.bg-selected, .flex.flex-col.items-center.justify-center.bg-secondary",
|
|
||||||
);
|
|
||||||
const count = await toggles.count();
|
|
||||||
expect(count).toBeGreaterThanOrEqual(3);
|
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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
await installWsFrameCapture(frigateApp.page);
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
await frigateApp.goto("/#front_door");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const live = new LivePage(frigateApp.page, true);
|
||||||
// Find active toggles (bg-selected class = feature is ON)
|
// Wait for feature toggles to render (WS camera_activity must arrive first).
|
||||||
const activeToggles = frigateApp.page.locator(
|
await expect(live.activeFeatureToggles.first()).toBeVisible({ timeout: 5_000 });
|
||||||
".flex.flex-col.items-center.justify-center.bg-selected",
|
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 ({
|
test("keyboard shortcut f does not crash", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/#front_door");
|
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// Find the gear icon button (last button-like element in header)
|
|
||||||
// The settings gear opens a dropdown with Stream, Play in background, etc.
|
|
||||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
|
||||||
const count = await gearButtons.count();
|
|
||||||
// Click the last one (gear icon is typically last in the header)
|
|
||||||
if (count > 0) {
|
|
||||||
await gearButtons.last().click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// A dropdown or drawer should appear
|
|
||||||
const overlay = frigateApp.page.locator(
|
|
||||||
'[role="menu"], [data-radix-menu-content], [role="dialog"]',
|
|
||||||
);
|
|
||||||
const visible = await overlay
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
if (visible) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keyboard shortcut f does not crash on desktop", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await frigateApp.page.keyboard.press("f");
|
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();
|
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,231 @@
|
|||||||
/**
|
/**
|
||||||
* Logs page tests -- MEDIUM tier.
|
* Logs page tests -- MEDIUM tier.
|
||||||
*
|
*
|
||||||
* Tests service tab switching by name, copy/download buttons,
|
* Service tabs (with real /logs/<service> JSON contract),
|
||||||
* and websocket message feed tab.
|
* log content render, Copy (clipboard), Download (assert
|
||||||
|
* ?download=true request fired), mobile tab selector.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
grantClipboardPermissions,
|
||||||
|
readClipboard,
|
||||||
|
} from "../helpers/clipboard";
|
||||||
|
|
||||||
test.describe("Logs Page - Service Tabs @medium", () => {
|
function logsJsonBody(lines: string[]) {
|
||||||
test("logs page renders with named service tabs", async ({ frigateApp }) => {
|
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 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({
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText(/Frigate started/),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching to go2rtc tab changes active tab", async ({ frigateApp }) => {
|
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 frigateApp.goto("/logs");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
|
const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc");
|
||||||
if (await go2rtcTab.isVisible().catch(() => false)) {
|
await expect(go2rtcTab).toBeVisible();
|
||||||
await go2rtcTab.click();
|
await go2rtcTab.click();
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
await expect.poll(() => go2rtcCalled, { timeout: 5_000 }).toBe(true);
|
||||||
await expect(go2rtcTab).toHaveAttribute("data-state", "on");
|
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.describe("Logs — actions @medium", () => {
|
||||||
test("copy to clipboard button is present and clickable", async ({
|
test("Copy button writes current logs to clipboard", async ({
|
||||||
frigateApp,
|
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 frigateApp.goto("/logs");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
await expect(
|
||||||
|
frigateApp.page.getByText(/Frigate started/),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
|
const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard");
|
||||||
if (await copyBtn.isVisible().catch(() => false)) {
|
await expect(copyBtn).toBeVisible({ timeout: 5_000 });
|
||||||
await copyBtn.click();
|
await copyBtn.click();
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await expect
|
||||||
// Should trigger clipboard copy (toast may appear)
|
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
|
||||||
}
|
.toContain("Frigate started");
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("download logs button is present", async ({ frigateApp }) => {
|
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");
|
await frigateApp.goto("/logs");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
|
const downloadBtn = frigateApp.page.getByLabel("Download Logs");
|
||||||
if (await downloadBtn.isVisible().catch(() => false)) {
|
await expect(downloadBtn).toBeVisible({ timeout: 5_000 });
|
||||||
await expect(downloadBtn).toBeVisible();
|
await downloadBtn.click();
|
||||||
}
|
await expect.poll(() => downloadCalled, { timeout: 5_000 }).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
test("logs page displays log content text", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/logs");
|
test.describe("Logs — websocket tab @medium", () => {
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
test("switching to websocket tab renders WsMessageFeed container", async ({
|
||||||
const text = await frigateApp.page.textContent("#pageRoot");
|
frigateApp,
|
||||||
expect(text?.length).toBeGreaterThan(0);
|
}) => {
|
||||||
|
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.getByLabel("Select frigate"),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,227 +1,161 @@
|
|||||||
/**
|
/**
|
||||||
* Navigation tests -- CRITICAL tier.
|
* Navigation tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests sidebar (desktop) and bottombar (mobile) navigation,
|
* Covers sidebar (desktop) / bottombar (mobile) link set, conditional
|
||||||
* conditional nav items, settings menus, and their actual behaviors.
|
* 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 { test, expect } from "../fixtures/frigate-test";
|
||||||
import { BasePage } from "../pages/base.page";
|
import { BasePage } from "../pages/base.page";
|
||||||
|
|
||||||
test.describe("Navigation @critical", () => {
|
const PRIMARY_ROUTES = ["/review", "/explore", "/export"] as const;
|
||||||
test("app loads and renders page root", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logo is visible and links to home", async ({ frigateApp }) => {
|
test.describe("Navigation — primary links @critical", () => {
|
||||||
if (frigateApp.isMobile) {
|
test("every primary link is visible and navigates", async ({
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const base = new BasePage(frigateApp.page, true);
|
|
||||||
const logo = base.sidebar.locator('a[href="/"]').first();
|
|
||||||
await expect(logo).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("all primary nav links are present and navigate", async ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const routes = ["/review", "/explore", "/export"];
|
for (const route of PRIMARY_ROUTES) {
|
||||||
for (const route of routes) {
|
|
||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
frigateApp.page.locator(`a[href="${route}"]`).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
// Verify clicking each one actually navigates
|
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
||||||
for (const route of routes) {
|
for (const route of PRIMARY_ROUTES) {
|
||||||
await base.navigateTo(route);
|
await base.navigateTo(route);
|
||||||
await expect(frigateApp.page).toHaveURL(new RegExp(route));
|
await expect(frigateApp.page).toHaveURL(new RegExp(route));
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop sidebar is visible, mobile bottombar is visible", async ({
|
test("logo links home on desktop", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
test.skip(frigateApp.isMobile, "Sidebar logo is desktop-only");
|
||||||
}) => {
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.goto("/");
|
await frigateApp.page.locator("aside a[href='/']").first().click();
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
if (!frigateApp.isMobile) {
|
|
||||||
await expect(base.sidebar).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(base.sidebar).not.toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigate between all main pages without crash", async ({
|
test("unknown route redirects to /", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
|
||||||
const pageRoot = frigateApp.page.locator("#pageRoot");
|
|
||||||
|
|
||||||
await base.navigateTo("/review");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
await base.navigateTo("/explore");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
await base.navigateTo("/export");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
await base.navigateTo("/review");
|
|
||||||
await expect(pageRoot).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unknown route redirects to home", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.page.goto("/nonexistent-route");
|
await frigateApp.page.goto("/nonexistent-route");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||||
const url = frigateApp.page.url();
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
const hasPageRoot = await frigateApp.page
|
await expect(
|
||||||
.locator("#pageRoot")
|
frigateApp.page.locator("[data-camera='front_door']"),
|
||||||
.isVisible()
|
).toBeVisible({ timeout: 10_000 });
|
||||||
.catch(() => false);
|
|
||||||
expect(url.endsWith("/") || hasPageRoot).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Navigation - Conditional Items @critical", () => {
|
test.describe("Navigation — conditional items @critical", () => {
|
||||||
test("Faces nav hidden when face_recognition disabled", async ({
|
test("/faces is hidden when face_recognition.enabled is false", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/");
|
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 }) => {
|
test("/faces is visible when face_recognition.enabled is true (desktop)", async ({
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
genai: {
|
|
||||||
enabled: false,
|
|
||||||
provider: "ollama",
|
|
||||||
model: "none",
|
|
||||||
base_url: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Faces nav visible when face_recognition enabled on desktop", async ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
config: { face_recognition: { enabled: true } },
|
config: { face_recognition: { enabled: true } },
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await expect(page.locator('a[href="/faces"]')).toBeVisible();
|
await expect(
|
||||||
|
frigateApp.page.locator('a[href="/faces"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Chat nav visible when genai model set on desktop", async ({
|
test("/chat is hidden when genai.model is none (desktop)", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
test.skip();
|
await frigateApp.installDefaults({
|
||||||
return;
|
config: {
|
||||||
}
|
genai: { enabled: false, provider: "ollama", model: "none", base_url: "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.locator('a[href="/chat"]').first(),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/chat is visible when genai.model is set (desktop)", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
config: { genai: { enabled: true, model: "llava" } },
|
config: { genai: { enabled: true, model: "llava" } },
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/");
|
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,
|
frigateApp,
|
||||||
page,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
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.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||||
test("settings gear opens menu with navigation items (desktop)", async ({
|
test.skip(
|
||||||
frigateApp,
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
}) => {
|
"Sidebar settings menu is desktop-only",
|
||||||
if (frigateApp.isMobile) {
|
);
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
|
||||||
// Settings gear is in the sidebar bottom section, a div with cursor-pointer
|
|
||||||
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
|
|
||||||
const gearIcon = sidebarBottom
|
|
||||||
.locator("div[class*='cursor-pointer']")
|
|
||||||
.first();
|
|
||||||
await expect(gearIcon).toBeVisible({ timeout: 5_000 });
|
|
||||||
await gearIcon.click();
|
|
||||||
// Menu should open - look for the "Settings" menu item by aria-label
|
|
||||||
await expect(frigateApp.page.getByLabel("Settings")).toBeVisible({
|
|
||||||
timeout: 3_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings menu items navigate to correct routes (desktop)", async ({
|
const TARGETS = [
|
||||||
frigateApp,
|
{ label: "Settings", url: /\/settings/ },
|
||||||
}) => {
|
{ label: "System metrics", url: /\/system/ },
|
||||||
if (frigateApp.isMobile) {
|
{ label: "System logs", url: /\/logs/ },
|
||||||
test.skip();
|
{ label: "Configuration Editor", url: /\/config/ },
|
||||||
return;
|
];
|
||||||
}
|
|
||||||
const targets = [
|
for (const target of TARGETS) {
|
||||||
{ label: "Settings", url: "/settings" },
|
test(`menu → ${target.label} navigates`, async ({ frigateApp }) => {
|
||||||
{ label: "System metrics", url: "/system" },
|
|
||||||
{ label: "System logs", url: "/logs" },
|
|
||||||
{ label: "Configuration Editor", url: "/config" },
|
|
||||||
];
|
|
||||||
for (const target of targets) {
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const gearIcon = frigateApp.page
|
const gear = frigateApp.page
|
||||||
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
||||||
.first();
|
.first();
|
||||||
await gearIcon.click();
|
await gear.click();
|
||||||
await frigateApp.page.waitForTimeout(300);
|
await frigateApp.page.getByLabel(target.label).click();
|
||||||
const menuItem = frigateApp.page.getByLabel(target.label);
|
await expect(frigateApp.page).toHaveURL(target.url);
|
||||||
if (await menuItem.isVisible().catch(() => false)) {
|
});
|
||||||
await menuItem.click();
|
}
|
||||||
await expect(frigateApp.page).toHaveURL(
|
});
|
||||||
new RegExp(target.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
||||||
);
|
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 ({
|
test("mobile nav survives route change", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||||
}) => {
|
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
const sidebarBottom = frigateApp.page.locator("aside .mb-8");
|
const reviewLink = frigateApp.page.locator('a[href="/review"]').first();
|
||||||
const items = sidebarBottom.locator("div[class*='cursor-pointer']");
|
await reviewLink.click();
|
||||||
const count = await items.count();
|
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||||
if (count >= 2) {
|
await expect(
|
||||||
await items.nth(1).click();
|
frigateApp.page.locator('a[href="/review"]').first(),
|
||||||
await frigateApp.page.waitForTimeout(500);
|
).toBeVisible();
|
||||||
}
|
|
||||||
await expect(frigateApp.page.locator("body")).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,307 @@
|
|||||||
/**
|
/**
|
||||||
* 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 { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
activeSessionStatus,
|
||||||
|
noSessionStatus,
|
||||||
|
} from "../fixtures/mock-data/debug-replay";
|
||||||
|
|
||||||
test.describe("Replay Page @low", () => {
|
async function installStatusRoute(
|
||||||
test("replay page renders without crash", async ({ frigateApp }) => {
|
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.goto("/replay");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
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.goto("/replay");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
await expect(
|
||||||
const buttons = frigateApp.page.locator("button");
|
frigateApp.page.getByRole("heading", {
|
||||||
const count = await buttons.count();
|
level: 2,
|
||||||
expect(count).toBeGreaterThan(0);
|
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,234 @@
|
|||||||
/**
|
/**
|
||||||
* Review/Events page tests -- CRITICAL tier.
|
* Review/Events page tests -- CRITICAL tier.
|
||||||
*
|
*
|
||||||
* Tests severity tab switching by name (Alerts/Detections/Motion),
|
* Severity tabs, filter popovers, calendar, show-reviewed toggle,
|
||||||
* filter popover opening with camera names, show reviewed toggle,
|
* timeline, and the nested-overlay regression migrated from
|
||||||
* calendar button, and filter button interactions.
|
* radix-overlay-regressions.spec.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
import { BasePage } from "../pages/base.page";
|
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.describe("Review — severity tabs @critical", () => {
|
||||||
test("severity tabs render with Alerts, Detections, Motion", async ({
|
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,
|
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 frigateApp.goto("/review");
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile);
|
||||||
timeout: 10_000,
|
await expect(review.alertsTab).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
await expect(review.alertsTab).toHaveAttribute("data-state", "on");
|
||||||
await expect(frigateApp.page.getByLabel("Detections")).toBeVisible();
|
await review.detectionsTab.click();
|
||||||
// Motion uses role="radio" to distinguish from other Motion elements
|
await expect(review.detectionsTab).toHaveAttribute("data-state", "on");
|
||||||
await expect(
|
await expect(review.alertsTab).toHaveAttribute("data-state", "off");
|
||||||
frigateApp.page.getByRole("radio", { name: "Motion" }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Alerts tab is active by default", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const alertsTab = frigateApp.page.getByLabel("Alerts");
|
|
||||||
await expect(alertsTab).toHaveAttribute("data-state", "on");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking Detections tab makes it active and deactivates Alerts", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const alertsTab = frigateApp.page.getByLabel("Alerts");
|
|
||||||
const detectionsTab = frigateApp.page.getByLabel("Detections");
|
|
||||||
|
|
||||||
await detectionsTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
|
|
||||||
await expect(detectionsTab).toHaveAttribute("data-state", "on");
|
|
||||||
await expect(alertsTab).toHaveAttribute("data-state", "off");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking Motion tab makes it active", async ({ frigateApp }) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" });
|
|
||||||
await motionTab.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
await expect(motionTab).toHaveAttribute("data-state", "on");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching back to Alerts from Detections works", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
await frigateApp.page.getByLabel("Detections").click();
|
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
await frigateApp.page.getByLabel("Alerts").click();
|
|
||||||
await frigateApp.page.waitForTimeout(300);
|
|
||||||
|
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Review Page - Filters @critical", () => {
|
test.describe("Review — filters (desktop) @critical", () => {
|
||||||
test("All Cameras filter button opens popover with camera names", async ({
|
test.skip(
|
||||||
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
"Filter bar differs on mobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Cameras popover lists configured camera names", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
const review = new ReviewPage(frigateApp.page, true);
|
||||||
|
await expect(review.camerasFilterTrigger).toBeVisible({ timeout: 5_000 });
|
||||||
const camerasBtn = frigateApp.page.getByRole("button", {
|
await review.camerasFilterTrigger.click();
|
||||||
name: /cameras/i,
|
await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 });
|
||||||
});
|
|
||||||
await expect(camerasBtn).toBeVisible({ timeout: 5_000 });
|
|
||||||
await camerasBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Popover should open with camera names from config
|
|
||||||
const popover = frigateApp.page.locator(
|
|
||||||
"[data-radix-popper-content-wrapper]",
|
|
||||||
);
|
|
||||||
await expect(popover.first()).toBeVisible({ timeout: 3_000 });
|
|
||||||
// Camera names should be present
|
|
||||||
await expect(frigateApp.page.getByText("Front Door")).toBeVisible();
|
await 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");
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Show Reviewed toggle is clickable", async ({ frigateApp }) => {
|
test("Zones popover lists configured zones inside the General Filter dialog", async ({
|
||||||
await frigateApp.goto("/review");
|
frigateApp,
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
}) => {
|
||||||
|
// Override config to guarantee a known zone on front_door.
|
||||||
const showReviewed = frigateApp.page.getByRole("button", {
|
await frigateApp.installDefaults({
|
||||||
name: /reviewed/i,
|
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 frigateApp.goto("/review");
|
||||||
await showReviewed.click();
|
const filterBtn = frigateApp.page
|
||||||
await frigateApp.page.waitForTimeout(500);
|
.getByRole("button", { name: /^filter$/i })
|
||||||
// Toggle should change state
|
.first();
|
||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/review");
|
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", {
|
// react-day-picker v9 renders a role="grid" calendar with day cells
|
||||||
name: /24 hours|calendar|date/i,
|
// as buttons inside gridcells (e.g. "Wednesday, April 1st, 2026").
|
||||||
});
|
// The calendar is placed directly in the DOM (not always inside a
|
||||||
if (await calendarBtn.isVisible().catch(() => false)) {
|
// Radix popper wrapper), so scope by the grid role instead.
|
||||||
await calendarBtn.click();
|
const calendarGrid = frigateApp.page.locator('[role="grid"]').first();
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await expect(calendarGrid).toBeVisible({ timeout: 3_000 });
|
||||||
// Popover should open
|
const dayButton = calendarGrid
|
||||||
const popover = frigateApp.page.locator(
|
.locator('[role="gridcell"] button')
|
||||||
"[data-radix-popper-content-wrapper]",
|
.first();
|
||||||
);
|
await expect(dayButton).toBeVisible({ timeout: 3_000 });
|
||||||
if (
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
await popover
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false)
|
|
||||||
) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Filter button opens filter popover", async ({ frigateApp }) => {
|
test("Show Reviewed switch flips its checked state", async ({
|
||||||
await frigateApp.goto("/review");
|
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
const filterBtn = frigateApp.page.getByRole("button", {
|
|
||||||
name: /^filter$/i,
|
|
||||||
});
|
|
||||||
if (await filterBtn.isVisible().catch(() => false)) {
|
|
||||||
await filterBtn.click();
|
|
||||||
await frigateApp.page.waitForTimeout(500);
|
|
||||||
// Popover or dialog should open
|
|
||||||
const popover = frigateApp.page.locator(
|
|
||||||
"[data-radix-popper-content-wrapper], [role='dialog']",
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
await popover
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false)
|
|
||||||
) {
|
|
||||||
await frigateApp.page.keyboard.press("Escape");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Review Page - Timeline @critical", () => {
|
|
||||||
test("review page has timeline with time markers (desktop)", async ({
|
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
if (frigateApp.isMobile) {
|
// "Show Reviewed" is a Radix Switch (role=switch), not a button.
|
||||||
test.skip();
|
// It filters review data client-side; it does not trigger a new
|
||||||
return;
|
// /api/review network request. Verify the switch state toggles.
|
||||||
}
|
|
||||||
await frigateApp.goto("/review");
|
await frigateApp.goto("/review");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
const showReviewedSwitch = frigateApp.page.getByRole("switch", {
|
||||||
// Timeline renders time labels like "4:30 PM"
|
name: /show reviewed/i,
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
});
|
||||||
expect(pageText).toMatch(/[AP]M/);
|
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.describe("Review — timeline (desktop) @critical", () => {
|
||||||
test("navigate to review from live page works", async ({ frigateApp }) => {
|
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("/");
|
await frigateApp.goto("/");
|
||||||
const base = new BasePage(frigateApp.page, !frigateApp.isMobile);
|
const base = new BasePage(frigateApp.page, false);
|
||||||
await base.navigateTo("/review");
|
await base.navigateTo("/review");
|
||||||
await expect(frigateApp.page).toHaveURL(/\/review/);
|
await expect(frigateApp.page).toHaveURL(/\/review/);
|
||||||
// Severity tabs should be visible
|
await base.navigateTo("/");
|
||||||
await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({
|
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.
|
* Tab switching, version + last-refreshed display, and the
|
||||||
* Navigates to /system#general explicitly so useHashState resolves
|
* RestartDialog cancel flow.
|
||||||
* the tab state deterministically.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import {
|
||||||
|
expectBodyInteractive,
|
||||||
|
waitForBodyInteractive,
|
||||||
|
} from "../helpers/overlay-interaction";
|
||||||
|
|
||||||
test.describe("System Page @medium", () => {
|
test.describe("System — tabs @medium", () => {
|
||||||
test("system page renders with tab buttons", async ({ frigateApp }) => {
|
test("general tab is active by default via #general hash", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@ -20,7 +26,7 @@ test.describe("System Page @medium", () => {
|
|||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
@ -29,18 +35,6 @@ test.describe("System Page @medium", () => {
|
|||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ 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 frigateApp.page.getByLabel("Select storage").click();
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
@ -53,29 +47,22 @@ test.describe("System Page @medium", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking Cameras tab activates it and deactivates General", async ({
|
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
await frigateApp.page.getByLabel("Select cameras").click();
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 5_000 },
|
{ 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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
@ -87,4 +74,167 @@ test.describe("System Page @medium", () => {
|
|||||||
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
|
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
|
||||||
await expect(frigateApp.page.getByText(/Last refreshed/)).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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user