Files
frigate/web/e2e/specs/ptz-overlay.spec.ts
T
Josh Hawkins 3b81416299 Update Radix deps (#22957)
* 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
2026-04-21 08:48:48 -06:00

199 lines
6.1 KiB
TypeScript

/**
* 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();
});
});