add tests

This commit is contained in:
Josh Hawkins 2026-04-21 09:40:37 -05:00
parent a3248b7aa8
commit f0eeedeb9f
3 changed files with 540 additions and 0 deletions

View File

@ -0,0 +1,41 @@
/**
* Overlay interaction helpers for Radix-based UI tests.
*
* These helpers exist to guard the class of bugs fixed by de-duping
* `@radix-ui/react-dismissable-layer` across the tree: body pointer-events
* getting stuck, dropdown typeahead breaking, tooltips re-popping after a
* dropdown closes, and related nested-overlay regressions.
*/
import { expect, type Page } from "@playwright/test";
/**
* Assert that `<body>` is interactive (no stuck `pointer-events: none`).
*
* Call after closing any overlay. This is the fast secondary assertion
* test specs should also assert a user-visible behavior like "a button
* responded to a click" so the test fails on meaningful breakage rather
* than just a CSS invariant.
*/
export async function expectBodyInteractive(page: Page) {
const stuck = await page.evaluate(
() => document.body.style.pointerEvents === "none",
);
expect(stuck, "body.style.pointer-events stuck after overlay close").toBe(
false,
);
}
/**
* Wait until the `<body>` is no longer marked with `pointer-events: none`.
*
* Useful right after closing an overlay when Radix's cleanup runs in the
* next frame. Throws if the style does not clear within `timeoutMs`.
*/
export async function waitForBodyInteractive(page: Page, timeoutMs = 2000) {
await page.waitForFunction(
() => document.body.style.pointerEvents !== "none",
null,
{ timeout: timeoutMs },
);
}

View File

@ -0,0 +1,198 @@
/**
* 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();
});
});

View File

@ -0,0 +1,301 @@
/**
* 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);
});
});