Files
frigate/web/e2e/specs/face-library.spec.ts
T
Josh Hawkins 962d36323b Improve frontend e2e tests (#22958)
* add mock data

* add helpers

* page objects

* updated specs

* remove PENDING_REWARITE

* formatting
2026-04-21 16:32:18 -06:00

391 lines
13 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.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);
});
});