From f0eeedeb9f3670f824b6c63e9cf8721700beb51b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:40:37 -0500 Subject: [PATCH] add tests --- web/e2e/helpers/overlay-interaction.ts | 41 +++ web/e2e/specs/ptz-overlay.spec.ts | 198 ++++++++++++ .../specs/radix-overlay-regressions.spec.ts | 301 ++++++++++++++++++ 3 files changed, 540 insertions(+) create mode 100644 web/e2e/helpers/overlay-interaction.ts create mode 100644 web/e2e/specs/ptz-overlay.spec.ts create mode 100644 web/e2e/specs/radix-overlay-regressions.spec.ts diff --git a/web/e2e/helpers/overlay-interaction.ts b/web/e2e/helpers/overlay-interaction.ts new file mode 100644 index 0000000000..81a01d8415 --- /dev/null +++ b/web/e2e/helpers/overlay-interaction.ts @@ -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 `
` 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 `` 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 }, + ); +} diff --git a/web/e2e/specs/ptz-overlay.spec.ts b/web/e2e/specs/ptz-overlay.spec.ts new file mode 100644 index 0000000000..06beb1788c --- /dev/null +++ b/web/e2e/specs/ptz-overlay.spec.ts @@ -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 would make the rest of the UI + // unclickable. + await expectBodyInteractive(frigateApp.page); + await expect(frigateApp.page.locator("body")).toBeVisible(); + }); +}); diff --git a/web/e2e/specs/radix-overlay-regressions.spec.ts b/web/e2e/specs/radix-overlay-regressions.spec.ts new file mode 100644 index 0000000000..aab0be92f9 --- /dev/null +++ b/web/e2e/specs/radix-overlay-regressions.spec.ts @@ -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