frigate/web/e2e/specs/face-library.spec.ts

541 lines
19 KiB
TypeScript
Raw Normal View History

/**
* Face Library page tests -- HIGH tier.
*
* Collection selector, face tiles, grouped recent-recognition dialog
* (migrated from radix-overlay-regressions.spec.ts), and mobile
* library selector.
*/
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";
const GROUPED_EVENT_ID = "1775487131.3863528-abc123";
function groupedFacesMock() {
return withGroupedTrainingAttempt(basicFacesMock(), {
eventId: GROUPED_EVENT_ID,
attempts: [
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
{ timestamp: 1775487132.3863528, label: "unknown", score: 0.91 },
],
});
}
async function installGroupedFaces(app: FrigateApp) {
await app.api.install({
events: [
{
id: GROUPED_EVENT_ID,
label: "person",
sub_label: null,
camera: "front_door",
start_time: 1775487131.3863528,
end_time: 1775487161.3863528,
false_positive: false,
zones: ["front_yard"],
thumbnail: null,
has_clip: true,
has_snapshot: true,
retain_indefinitely: false,
plus_id: null,
model_hash: "abc123",
detector_type: "cpu",
model_type: "ssd",
data: {
top_score: 0.92,
score: 0.92,
region: [0.1, 0.1, 0.5, 0.8],
box: [0.2, 0.15, 0.45, 0.75],
area: 0.18,
ratio: 0.6,
type: "object",
path_data: [],
},
},
],
faces: groupedFacesMock(),
});
}
async function openGroupedFaceDialog(app: FrigateApp): Promise<Locator> {
await installGroupedFaces(app);
await app.goto("/faces");
const groupedImage = app.page
.locator('img[src*="clips/faces/train/"]')
.first();
const groupedCard = groupedImage.locator("xpath=..");
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
await groupedCard.click();
const dialog = app.page
.getByRole("dialog")
.filter({ has: app.page.locator('img[src*="clips/faces/train/"]') })
.first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.locator('img[src*="clips/faces/train/"]')).toHaveCount(2);
return dialog;
}
/**
* Opens the LibrarySelector dropdown (the single button at the top-left of
* the Face Library page) and returns the dropdown menu locator.
*
* The LibrarySelector is a single DropdownMenu whose trigger shows the
* current tab name + count (e.g. "Recent Recognitions (0)"). Named face
* collections (alice, bob, charlie) are items inside this dropdown.
*/
async function openLibraryDropdown(app: FrigateApp): Promise<Locator> {
// The trigger is the first button on the page with a parenthesised count.
const trigger = app.page
.getByRole("button")
.filter({ hasText: /\(\d+\)/ })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const menu = app.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
return menu;
}
test.describe("Face Library — collection selector @high", () => {
test("selector shows named face collections", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.goto("/faces");
// Named collections appear in the LibrarySelector dropdown.
const menu = await openLibraryDropdown(frigateApp);
await expect(menu.getByText(/alice/i).first()).toBeVisible({
timeout: 5_000,
});
});
test("empty state renders when no faces exist", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: emptyFacesMock() });
await frigateApp.goto("/faces");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
await expect(
frigateApp.page.locator('img[src*="/clips/faces/"]'),
).toHaveCount(0);
});
test("tiles render for each named collection", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.goto("/faces");
// Open the dropdown — collections list shows "alice (2)" and "bob (1)".
const menu = await openLibraryDropdown(frigateApp);
await expect(
menu.locator('[role="menuitem"]').filter({ hasText: /alice/i }).first(),
).toBeVisible({ timeout: 5_000 });
await expect(
menu.locator('[role="menuitem"]').filter({ hasText: /bob/i }).first(),
).toBeVisible();
});
});
test.describe("Face Library — delete flow (desktop) @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Delete action menu is desktop-focused",
);
test("deleting a collection fires POST /faces/<name>/delete", async ({
frigateApp,
}) => {
let deleteUrl: string | null = null;
let deleteBody: unknown = null;
// Install base mocks first, then register our more-specific route AFTER
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.page.route(
/\/api\/faces\/[^/]+\/delete/,
async (route) => {
deleteUrl = route.request().url();
deleteBody = route.request().postDataJSON();
await route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/faces");
// Open the LibrarySelector dropdown and click the trash icon next
// to the alice row. The trash icon is a ghost-variant Button inside
// the DropdownMenuItem — it becomes visible on hover/focus.
const menu = await openLibraryDropdown(frigateApp);
const aliceRow = menu
.locator('[role="menuitem"]')
.filter({ hasText: /alice/i })
.first();
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
// Hover first to make hover-only opacity-0 buttons visible.
await aliceRow.hover();
// The icon buttons have no aria-label or title. The row renders exactly
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
// (delete). This order is determined by FaceLibrary.tsx and is stable.
const trashBtn = aliceRow.locator("button").nth(1);
await trashBtn.click();
// The delete confirmation is a Dialog (not AlertDialog) in this flow.
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog
.getByRole("button", { name: /delete/i })
.first()
.click();
await expect
.poll(() => deleteUrl, { timeout: 5_000 })
.toMatch(/\/faces\/alice\/delete/);
expect(deleteBody).toMatchObject({ ids: expect.any(Array) });
});
});
test.describe("Face Library — rename flow (desktop) @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Rename action menu is desktop-focused",
);
test("renaming a collection fires PUT /faces/<name>/rename", async ({
frigateApp,
}) => {
let renameUrl: string | null = null;
let renameBody: unknown = null;
// Install base mocks first, then register our more-specific route AFTER
// so it takes priority over the ApiMocker catch-all (Playwright LIFO order).
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.page.route(
/\/api\/faces\/[^/]+\/rename/,
async (route) => {
renameUrl = route.request().url();
renameBody = route.request().postDataJSON();
await route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/faces");
// Open the LibrarySelector dropdown and click the pencil (rename) icon
// next to alice. The icon is a ghost Button inside the DropdownMenuItem.
const menu = await openLibraryDropdown(frigateApp);
const aliceRow = menu
.locator('[role="menuitem"]')
.filter({ hasText: /alice/i })
.first();
await expect(aliceRow).toBeVisible({ timeout: 5_000 });
await aliceRow.hover();
// The icon buttons have no aria-label or title. The row renders exactly
// two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2
// (delete). This order is determined by FaceLibrary.tsx and is stable.
const pencilBtn = aliceRow.locator("button").nth(0);
await pencilBtn.click();
// TextEntryDialog — fill the input and confirm.
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.locator("input").first().fill("alice_renamed");
await dialog
.getByRole("button", { name: /save|rename|confirm/i })
.first()
.click();
await expect
.poll(() => renameUrl, { timeout: 5_000 })
.toMatch(/\/faces\/alice\/rename/);
expect(renameBody).toEqual({ new_name: "alice_renamed" });
});
});
test.describe("Face Library — upload flow @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Upload button has no accessible text on mobile — icon-only on narrow viewports",
);
test("Upload button opens the upload dialog", async ({ frigateApp }) => {
await frigateApp.installDefaults({ faces: basicFacesMock() });
await frigateApp.goto("/faces");
// Navigate to the alice tab by opening the dropdown and clicking alice.
const menu = await openLibraryDropdown(frigateApp);
await menu
.locator('[role="menuitem"]')
.filter({ hasText: /alice/i })
.first()
.click();
// After switching to alice, the Upload Image button appears in the toolbar.
const uploadBtn = frigateApp.page
.getByRole("button")
.filter({ hasText: /upload/i })
.first();
await expect(uploadBtn).toBeVisible({ timeout: 5_000 });
await uploadBtn.click();
// UploadImageDialog renders a file input + confirm button.
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.locator('input[type="file"]')).toHaveCount(1);
});
});
test.describe("FaceSelectionDialog @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Grouped dropdown flow is desktop-only",
);
test("reclassify dropdown selects a name and closes cleanly", async ({
frigateApp,
}) => {
// Migrated from radix-overlay-regressions.spec.ts.
const dialog = await openGroupedFaceDialog(frigateApp);
const triggers = dialog.locator('[aria-haspopup="menu"]');
await expect(triggers).toHaveCount(2);
await triggers.first().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await expect(dialog).toBeVisible();
const tooltipVisible = await frigateApp.page
.locator('[role="tooltip"]')
.filter({ hasText: /train face/i })
.isVisible()
.catch(() => false);
expect(
tooltipVisible,
"Train Face tooltip popped after dropdown closed — focus-restore regression",
).toBe(false);
});
test("second dropdown open accepts typeahead keyboard input", async ({
frigateApp,
}) => {
// Migrated from radix-overlay-regressions.spec.ts.
const dialog = await openGroupedFaceDialog(frigateApp);
const triggers = dialog.locator('[aria-haspopup="menu"]');
await expect(triggers).toHaveCount(2);
await triggers.first().click();
let menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByRole("menuitem", { name: /^bob$/i }).click();
await expect(menu).not.toBeVisible({ timeout: 3_000 });
await triggers.nth(1).click();
menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await frigateApp.page.keyboard.press("c");
await expect
.poll(
async () =>
frigateApp.page.evaluate(
() =>
document.activeElement?.textContent?.trim().toLowerCase() ?? "",
),
{ timeout: 2_000 },
)
.toMatch(/^charlie/);
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible({ timeout: 3_000 });
});
test("classifying the last image in a group leaves body interactive", async ({
frigateApp,
}) => {
// Regression guard for the stuck body pointer-events bug when the
// last image in a grouped-recognition detail Dialog is classified.
// Tracked upstream at radix-ui/primitives#3445.
//
// Root cause: when the user clicks a FaceSelectionDialog menu item,
// the modal DropdownMenu enters its exit animation (Radix's Presence
// keeps it in the DOM with data-state="closed" until animationend).
// While that is in flight the classify axios resolves, SWR removes
// the image from /api/faces, the parent's map no longer renders the
// grouped card, and React unmounts the subtree — including the still-
// animating DropdownMenu's Presence container. DismissableLayer's
// shared modal-layer stack can't reconcile the interrupted exit, so
// the `body { pointer-events: none }` entry it put on mount is never
// popped and the rest of the UI becomes unclickable.
//
// The fix is `modal={false}` on the FaceSelectionDialog's
// DropdownMenu (desktop path only). With modal=false the DropdownMenu
// never puts an entry on DismissableLayer's body-pointer-events stack
// in the first place, so there's nothing to leak when its Presence is
// torn down mid-animation. The Radix-community-documented workaround
// for #3445.
//
// The bug only reproduces when the mock resolves fast enough that
// the parent unmounts before the dropdown's exit animation finishes.
// Measured window via a 3x sweep on the pre-fix build: 0200 ms
// triggers it; 300 ms+ no longer reproduces. Production LAN networks
// sit comfortably inside the bad window, while `npm run dev` seems
// to mask it via React StrictMode's double-effect scheduling.
const EVENT_ID = "1775487131.3863528-race";
const initialFaces = withGroupedTrainingAttempt(basicFacesMock(), {
eventId: EVENT_ID,
attempts: [
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
],
});
let classified = false;
await frigateApp.installDefaults({
faces: initialFaces,
events: [
{
id: 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: [],
},
},
],
});
// Re-route /api/faces to flip to the "train empty" payload once the
// classify POST has been received. Registered AFTER installDefaults so
// Playwright's LIFO route matching hits this handler first.
await frigateApp.page.route("**/api/faces", async (route) => {
const payload = classified ? basicFacesMock() : initialFaces;
await route.fulfill({ json: payload });
});
// Hold the classify POST briefly. The race opens when the parent
// unmounts before the dropdown's exit animation finishes (~200ms
// in Radix). 100ms keeps us comfortably inside that window and
// reliably triggered the bug in a 3x sweep across 0/50/100/200ms
// on the pre-fix build. CLASSIFY_DELAY_MS overrides for local sweeps.
const delayMs = Number(
(globalThis as { process?: { env?: Record<string, string> } }).process
?.env?.CLASSIFY_DELAY_MS ?? "100",
);
await frigateApp.page.route(
"**/api/faces/train/*/classify",
async (route) => {
classified = true;
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/faces");
// Open the grouped detail Dialog.
const groupedImage = frigateApp.page
.locator('img[src*="clips/faces/train/"]')
.first();
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
await groupedImage.locator("xpath=..").click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({ has: frigateApp.page.locator('img[src*="clips/faces/train/"]') })
.first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Single attempt → single `+` trigger.
const triggers = dialog.locator('[aria-haspopup="menu"]');
await expect(triggers).toHaveCount(1);
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: /^alice$/i }).click();
// The Dialog must leave the tree cleanly, and body must recover.
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
// Give Radix's exit animation + cleanup a comfortable margin on top of
// the ~300ms simulated network delay.
await waitForBodyInteractive(frigateApp.page, 5_000);
await expectBodyInteractive(frigateApp.page);
// User-visible confirmation: click something outside the dialog
// and assert it actually responds.
const librarySelector = frigateApp.page
.getByRole("button")
.filter({ hasText: /\(\d+\)/ })
.first();
await librarySelector.click();
await expect(
frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first(),
).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);
});
});