Files
frigate/web/e2e/specs/clone-camera.spec.ts
Josh Hawkins bc65713ae4 Clone camera settings (#23339)
* add clone dialog

* i18n

* tweaks

* add to camera management pane

* add e2e test

* optional disable portal prop

* radio and checkbox tweaks

* tweak i18n

* add select all/select none

* fixes

* reset form only on open transition

* unselect all targets for existing camera

* fix test

* reorder sections for save and collapse to single put for new camera

* change source and allow cloning to multiple cameras

* tweak language

* fix overflowing text in save all popover

* tweaks

* fix per label object masks

* use grid for source and target

* language tweak
2026-05-28 17:44:06 -06:00

182 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Camera clone dialog E2E tests.
*
* Covers the design invariants that don't depend on per-camera resolution
* differences in the mock fixture:
* 1. Dialog opens from the "Clone settings" button below Add/Delete.
* 2. A source camera must be chosen inside the dialog before cloning.
* 3. "Stream URLs and roles" is forced on and disabled for new-camera target.
* 4. Cloning to a new camera issues a single add PUT and shows a restart prompt.
* 5. The existing-camera target selects multiple destinations via a switch
* popover (with an "All cameras" toggle and source exclusion); the closed
* trigger summarizes the selection by name or as "All cameras".
*
* The spatial-mismatch warning path is exercised in unit-level review and via
* manual QA — the shared mock fixture ships every camera at 1280×720. The
* existing-camera PUT fan-out is likewise not asserted here: the mock cameras
* are identical apart from stream URLs (which existing-camera clones never
* copy) and the schema mock is empty, so a clone onto them produces no diff
* and no PUT. That path is covered by unit-level review and manual QA.
*/
import { test, expect } from "../fixtures/frigate-test";
async function openCloneDialog(frigateApp: {
page: import("@playwright/test").Page;
}) {
await frigateApp.page
.getByRole("button", { name: /^Clone settings$/i })
.click();
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
}
async function selectSource(
frigateApp: { page: import("@playwright/test").Page },
source: string,
) {
await frigateApp.page.getByRole("dialog").getByRole("combobox").click();
await frigateApp.page
.getByRole("option", { name: source, exact: true })
.click();
}
test.describe("Camera clone dialog @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
});
test("opens the dialog from the Clone settings button", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await expect(
frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i),
).toBeVisible();
// The Clone button is disabled until a source (and target) is chosen.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeDisabled();
});
test("forces Stream URLs and roles on for new-camera target", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
// The "New camera" radio is selected by default; the Streams group renders
// the ffmpeg_live checkbox as forced-checked and disabled.
const streamsLabel = frigateApp.page
.locator("label")
.filter({ hasText: /Stream URLs and roles/i });
await expect(streamsLabel).toBeVisible();
const streamsCheckbox = streamsLabel.getByRole("checkbox");
await expect(streamsCheckbox).toBeChecked();
await expect(streamsCheckbox).toBeDisabled();
});
test("issues a single add PUT and shows restart toast for new-camera target", async ({
frigateApp,
}) => {
const requests: { body: unknown }[] = [];
await frigateApp.page.route("**/api/config/set", async (route) => {
const body = route.request().postDataJSON();
requests.push({ body });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, require_restart: false }),
});
});
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
const nameInput = frigateApp.page.getByPlaceholder(
/e\.g\., back_door or Back Door/i,
);
await nameInput.fill("clone_target_one");
// With a source picked and a valid name, changeCount > 0 enables Clone.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeEnabled({ timeout: 5_000 });
await frigateApp.page.getByRole("button", { name: /^Clone$/i }).click();
// New-camera clones bundle into a single atomic add PUT (avoids
// per-section validation ordering issues).
await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(1);
const firstBody = requests[0].body as {
requires_restart?: number;
update_topic?: string;
};
expect(firstBody.update_topic).toMatch(
/config\/cameras\/clone_target_one\/add/,
);
expect(firstBody.requires_restart).toBe(1);
// The toast offers a Restart action because new-camera always needs restart.
// .first() avoids strict-mode rejection when both the toast action and the
// RestartDialog trigger render concurrently.
await expect(
frigateApp.page.getByRole("button", { name: /Restart/i }).first(),
).toBeVisible({ timeout: 8_000 });
});
test("selects multiple existing destination cameras via a switch popover", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
await frigateApp.page
.getByRole("radio", { name: /Existing cameras/i })
.click();
const dialog = frigateApp.page.getByRole("dialog");
// The destination trigger starts with the empty-selection placeholder.
await dialog
.getByRole("button", { name: /Select at least one camera/i })
.click();
// The chosen source is excluded from the destination switch list.
await expect(
dialog.getByRole("switch", { name: /Backyard/i }),
).toBeVisible();
await expect(dialog.getByRole("switch", { name: /Garage/i })).toBeVisible();
await expect(
dialog.getByRole("switch", { name: /^Front Door$/i }),
).toHaveCount(0);
// Selecting a single camera summarizes by name once the popover closes.
await dialog.getByRole("switch", { name: /Backyard/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^Backyard$/i }),
).toBeVisible();
// Reopen and select everything; the trigger collapses to "All cameras".
await dialog.getByRole("button", { name: /^Backyard$/i }).click();
await dialog.getByRole("switch", { name: /^All cameras$/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^All cameras$/i }),
).toBeVisible();
});
});