mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-22 15:07:41 +08:00
3b81416299
* Bump radix-ui packages to align react-dismissable-layer version and fix nested overlay pointer-events bug * remove workarounds for radix pointer events issues on dropdown and context menus * remove disablePortal from popover * remove modal on popovers * remove workarounds in restart dialog * keep onCloseAutoFocus for face, classification, and ptz these are necessary to prevent tooltips from re-showing and from the arrow keys from reopening the ptz presets menu * add tests
302 lines
9.6 KiB
TypeScript
302 lines
9.6 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|