mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-01 19:17:41 +03:00
* reset several dropdown and context menus to non-modal * add specific e2e test to confirm pointer events bug
541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
/**
|
||
* 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: 0–200 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);
|
||
});
|
||
});
|