/** * UI settings import/export tests -- MEDIUM tier. * * Covers exporting browser-stored layouts, streaming settings, and UI * preferences to a file, and importing that file back. */ import { readFileSync } from "node:fs"; import { test, expect } from "../../fixtures/frigate-test"; import type { Page } from "@playwright/test"; // the mocked profile is admin, so useUserPersistence keys are namespaced const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin"; const STREAMING_KEY = "streaming-settings:admin"; // the shape DraggableGridLayout writes const OUTDOOR_LAYOUT = { version: 2, naturalAspect: false, layout: [ { i: "front_door", x: 0, y: 0, w: 32, h: 72 }, { i: "backyard", x: 32, y: 0, w: 32, h: 72 }, ], }; const NATURAL_OUTDOOR_LAYOUT = { version: 2, naturalAspect: true, layout: [ { i: "front_door", x: 0, y: 0, w: 32, h: 72 }, { i: "backyard", x: 32, y: 0, w: 24, h: 96 }, ], }; const LEGACY_OUTDOOR_LAYOUT = [ { i: "front_door", x: 0, y: 0, w: 6, h: 4 }, { i: "backyard", x: 6, y: 0, w: 6, h: 4 }, ]; // a camera as an export from before streaming technology selection carries // it, so tests can assert both what a pre-feature file writes and what the // technology adds on top const FRONT_DOOR_WITHOUT_TECHNOLOGY = { streamName: "front_door", streamType: "smart", compatibilityMode: false, playAudio: false, volume: 1, }; const STREAMING_SETTINGS = { outdoor: { front_door: { ...FRONT_DOOR_WITHOUT_TECHNOLOGY, playerMode: "webrtc" }, }, }; async function writeIdb(page: Page, entries: Record) { await page.evaluate(async (data) => { await new Promise((resolve, reject) => { const request = indexedDB.open("keyval-store", 1); request.onupgradeneeded = () => request.result.createObjectStore("keyval"); request.onerror = () => reject(request.error); request.onsuccess = () => { const tx = request.result.transaction("keyval", "readwrite"); const store = tx.objectStore("keyval"); Object.entries(data).forEach(([key, value]) => store.put(value, key)); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }; }); }, entries); } async function readIdb(page: Page, key: string) { return page.evaluate(async (target) => { return new Promise((resolve, reject) => { const request = indexedDB.open("keyval-store", 1); request.onupgradeneeded = () => request.result.createObjectStore("keyval"); request.onerror = () => reject(request.error); request.onsuccess = () => { const tx = request.result.transaction("keyval", "readonly"); const get = tx.objectStore("keyval").get(target); get.onsuccess = () => resolve(get.result ?? null); get.onerror = () => reject(get.error); }; }); }, key); } function importPayload(overrides: Record = {}) { return { type: "frigate-ui-settings", version: 1, exported_at: "2026-08-17T14:22:31.000Z", frigate_version: "0.15.0-test", sections: { layouts: { outdoor: OUTDOOR_LAYOUT, patio: OUTDOOR_LAYOUT }, streaming: STREAMING_SETTINGS, preferences: { playbackRate: 2, weekStartsOn: 1 }, }, ...overrides, }; } async function chooseImportText(page: Page, contents: string) { await page.locator('input[type="file"]').setInputFiles({ name: "frigate-ui-settings-2026-08-17.json", mimeType: "application/json", buffer: Buffer.from(contents), }); } async function chooseImportFile(page: Page, payload: unknown) { await chooseImportText(page, JSON.stringify(payload)); } async function confirmImport(page: Page) { // Import triggers a real window.location.reload(), not a client-side // route change, so #pageRoot is already present and waiting for it // alone can race the navigation. Wait for the "load" event first so // the evaluate() calls below run against the post-reload document. await Promise.all([ page.waitForEvent("load"), page.getByRole("button", { name: "Import" }).click(), ]); await page.waitForSelector("#pageRoot"); } async function clearIdb(page: Page) { await page.evaluate(async () => { await new Promise((resolve, reject) => { const request = indexedDB.open("keyval-store", 1); request.onupgradeneeded = () => request.result.createObjectStore("keyval"); request.onerror = () => reject(request.error); request.onsuccess = () => { const tx = request.result.transaction("keyval", "readwrite"); tx.objectStore("keyval").clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }; }); }); } test.describe("UI settings import/export @medium", () => { test("exports stored layouts, streaming settings, and preferences", async ({ frigateApp, }) => { await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT, [STREAMING_KEY]: STREAMING_SETTINGS, "playbackRate:admin": 2, }); const downloadPromise = frigateApp.page.waitForEvent("download"); await frigateApp.page .getByRole("button", { name: "Export Settings" }) .click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch( /^frigate-ui-settings-\d{4}-\d{2}-\d{2}\.json$/, ); const path = await download.path(); const payload = JSON.parse(readFileSync(path!, "utf-8")); expect(payload.type).toBe("frigate-ui-settings"); expect(payload.version).toBe(1); expect(payload.frigate_version).toBe("0.15.0-test"); expect(payload.sections.layouts.outdoor).toEqual(OUTDOOR_LAYOUT); expect(payload.sections.streaming).toEqual(STREAMING_SETTINGS); expect(payload.sections.preferences.playbackRate).toBe(2); }); test("exports only layouts built for the current tile sizing mode", async ({ frigateApp, }) => { // a group not opened since the mode changed still holds a layout from // the other mode, which would import into a mode that cannot show it await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT, "default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT, "naturalAspectLayout:admin": true, }); const downloadPromise = frigateApp.page.waitForEvent("download"); await frigateApp.page .getByRole("button", { name: "Export Settings" }) .click(); const download = await downloadPromise; const payload = JSON.parse(readFileSync((await download.path())!, "utf-8")); expect(payload.sections.layouts).toEqual({ default: NATURAL_OUTDOOR_LAYOUT, }); }); test("toggling tile sizing mode clears stored layouts", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "The setting is hidden on phones"); await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT }); await frigateApp.page.locator("#natural-aspect-desktop").click(); await frigateApp.page .getByRole("alertdialog") .getByRole("button", { name: "Enable" }) .click(); await expect .poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)) .toBeNull(); expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( true, ); }); test("round-trips a layout left unconverted by an upgrade", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); // DraggableGridLayout rewrites a pre-0.19 layout only when that group's // dashboard is opened, so exporting first carries the bare array into the // file. Import must accept it back rather than rejecting the whole file. await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT, "playbackRate:admin": 2, }); const downloadPromise = frigateApp.page.waitForEvent("download"); await frigateApp.page .getByRole("button", { name: "Export Settings" }) .click(); const download = await downloadPromise; const contents = readFileSync((await download.path())!, "utf-8"); expect(JSON.parse(contents).sections.layouts.outdoor).toEqual( LEGACY_OUTDOOR_LAYOUT, ); await clearIdb(frigateApp.page); await chooseImportText(frigateApp.page, contents); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( LEGACY_OUTDOOR_LAYOUT, ); // the rest of the file must survive alongside it expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2); }); test("legacy layouts import turns natural aspect off so they display", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); // Bare-array layouts only render in bucketed mode; with natural aspect on // they would be discarded and regenerated on the next dashboard visit. The // import applies the mode the layouts were built for, and the file's own // naturalAspectLayout preference must not override that. await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true }); await chooseImportFile( frigateApp.page, importPayload({ sections: { layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT }, streaming: {}, preferences: { naturalAspectLayout: true }, }, }), ); const note = frigateApp.page.getByText(/standard tile sizing/); await expect(note).toBeVisible(); // the note is about the layouts section, so it follows its switch await frigateApp.page.getByText("Camera group layouts (1 group)").click(); await expect(note).toBeHidden(); await frigateApp.page.getByText("Camera group layouts (1 group)").click(); await expect(note).toBeVisible(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( LEGACY_OUTDOOR_LAYOUT, ); expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( false, ); }); test("natural aspect layouts import turns the setting on", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile( frigateApp.page, importPayload({ sections: { layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT }, streaming: {}, preferences: {}, }, }), ); await expect( frigateApp.page.getByText(/camera aspect ratio tile sizing/), ).toBeVisible(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( NATURAL_OUTDOOR_LAYOUT, ); expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( true, ); }); test("omits settings that were never stored", async ({ frigateApp }) => { await frigateApp.goto("/settings?page=uiSettings"); const downloadPromise = frigateApp.page.waitForEvent("download"); await frigateApp.page .getByRole("button", { name: "Export Settings" }) .click(); const download = await downloadPromise; const path = await download.path(); const payload = JSON.parse(readFileSync(path!, "utf-8")); expect(payload.sections.layouts).toEqual({}); expect(payload.sections.streaming).toEqual({}); expect(payload.sections.preferences.playbackRate).toBeUndefined(); }); test("imports every section and reloads", async ({ frigateApp }) => { await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile(frigateApp.page, importPayload()); await expect( frigateApp.page.getByText("Camera group layouts (2 groups)"), ).toBeVisible(); await expect( frigateApp.page.getByText("Streaming settings (1 camera)"), ).toBeVisible(); await expect( frigateApp.page.getByText("UI preferences (2 settings)"), ).toBeVisible(); // patio is layout-only, so its warning follows the layouts section await expect(frigateApp.page.getByText(/patio/)).toBeVisible({ visible: !frigateApp.isMobile, }); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( frigateApp.isMobile ? null : OUTDOOR_LAYOUT, ); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( STREAMING_SETTINGS, ); expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2); }); test("hides the unknown-group warning when layouts are switched off", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); await frigateApp.goto("/settings?page=uiSettings"); // patio is a layout-only group absent from this server, so the warning // is meaningful only while the layouts section is being imported await chooseImportFile(frigateApp.page, importPayload()); const warning = frigateApp.page.getByText(/patio/); await expect(warning).toBeVisible(); await frigateApp.page.getByText("Camera group layouts (2 groups)").click(); await expect(warning).toBeHidden(); await frigateApp.page.getByText("Camera group layouts (2 groups)").click(); await expect(warning).toBeVisible(); }); test("leaves a section untouched when its switch is off", async ({ frigateApp, }) => { await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [STREAMING_KEY]: {} }); await chooseImportFile(frigateApp.page, importPayload()); await frigateApp.page.getByText("Streaming settings (1 camera)").click(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({}); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( frigateApp.isMobile ? null : OUTDOOR_LAYOUT, ); }); test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => { test.skip(!frigateApp.isMobile, "Phone-only"); await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false }); await chooseImportFile(frigateApp.page, importPayload()); await expect( frigateApp.page.getByText(/aren't imported on phones/), ).toBeVisible(); // the section is still listed, but cannot be switched on await expect( frigateApp.page.getByText("Camera group layouts (2 groups)"), ).toBeVisible(); await expect( frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'), ).toBeDisabled(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( STREAMING_SETTINGS, ); // a layouts import is what flips this, so it must stay put expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( false, ); }); test("rejects a file that is not a Frigate settings export", async ({ frigateApp, }) => { await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile(frigateApp.page, { type: "something-else" }); await expect( frigateApp.page.getByText( "Failed to import settings: file is not a Frigate settings export", ), ).toBeVisible(); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); }); test("ignores preference keys that are not in the registry", async ({ frigateApp, }) => { await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile( frigateApp.page, importPayload({ sections: { layouts: {}, streaming: {}, preferences: { playbackRate: 2, notARealSetting: "malicious" }, }, }), ); await expect( frigateApp.page.getByText("UI preferences (1 setting)"), ).toBeVisible(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, "notARealSetting")).toBeNull(); expect(await readIdb(frigateApp.page, "notARealSetting:admin")).toBeNull(); expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2); }); test("round trips an export back through import", async ({ frigateApp }) => { await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT, [STREAMING_KEY]: STREAMING_SETTINGS, "playbackRate:admin": 2, "weekStartsOn:admin": 1, }); const downloadPromise = frigateApp.page.waitForEvent("download"); await frigateApp.page .getByRole("button", { name: "Export Settings" }) .click(); const download = await downloadPromise; const filename = download.suggestedFilename(); const path = await download.path(); const bytes = readFileSync(path!); await clearIdb(frigateApp.page); // A plain page.reload() is not enough here: the settings view strips // the `page` query param from the URL (history.replaceState) shortly // after mount, and on mobile that param is what reopens the uiSettings // drawer. Re-navigate with the param present so the reload is a real // fresh load of the uiSettings pane rather than the settings list. await frigateApp.goto("/settings?page=uiSettings"); // A no-op import would still pass every assertion below, so prove the // store is actually empty before feeding the export back in. expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); await frigateApp.page.locator('input[type="file"]').setInputFiles({ name: filename, mimeType: "application/json", buffer: Buffer.from(bytes), }); await expect( frigateApp.page.getByText("UI preferences (2 settings)"), ).toBeVisible(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( frigateApp.isMobile ? null : OUTDOOR_LAYOUT, ); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( STREAMING_SETTINGS, ); expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2); expect(await readIdb(frigateApp.page, "weekStartsOn:admin")).toBe(1); }); test("merges imported streaming settings with existing groups and cameras", async ({ frigateApp, }) => { // indoor is a group the file never mentions. outdoor.backyard is a // camera inside a group the file DOES mention, so a shallow group-level // merge would silently drop it. Both must survive. const EXISTING_STREAMING = { indoor: { garage: { streamName: "garage", streamType: "continuous", playerMode: "jsmpeg", compatibilityMode: true, playAudio: true, volume: 0.5, }, }, outdoor: { backyard: { streamName: "backyard", streamType: "no-streaming", compatibilityMode: false, playAudio: false, volume: 0, }, }, }; await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [STREAMING_KEY]: EXISTING_STREAMING }); await chooseImportFile(frigateApp.page, importPayload()); await expect( frigateApp.page.getByText("Streaming settings (1 camera)"), ).toBeVisible(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({ indoor: EXISTING_STREAMING.indoor, outdoor: { ...EXISTING_STREAMING.outdoor, ...STREAMING_SETTINGS.outdoor, }, }); }); test("drops only the streaming technology when its value is unrecognized", async ({ frigateApp, }) => { // a hand-edited file, or an export from a future Frigate that added a // technology this build does not know: the camera's other settings still // import rather than the whole file failing validation await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile( frigateApp.page, importPayload({ sections: { layouts: {}, streaming: { outdoor: { front_door: { ...FRONT_DOOR_WITHOUT_TECHNOLOGY, playerMode: "quantum", }, }, }, preferences: {}, }, }), ); await expect( frigateApp.page.getByText("Streaming settings (1 camera)"), ).toBeVisible(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({ outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY }, }); }); test("clears a stored streaming technology when the file predates it", async ({ frigateApp, }) => { // import replaces whole camera objects, as it already does for streamName // and volume, so a pre-feature export resets the technology rather than // leaving the local choice in place await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { [STREAMING_KEY]: STREAMING_SETTINGS }); await chooseImportFile( frigateApp.page, importPayload({ sections: { layouts: {}, streaming: { outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY }, }, preferences: {}, }, }), ); await expect( frigateApp.page.getByText("Streaming settings (1 camera)"), ).toBeVisible(); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({ outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY }, }); }); test("rejects a file that is not valid JSON", async ({ frigateApp }) => { await frigateApp.goto("/settings?page=uiSettings"); await chooseImportText(frigateApp.page, "this is not json"); await expect( frigateApp.page.getByText( "Failed to import settings: file is not valid JSON", ), ).toBeVisible(); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); }); test("rejects a file from a newer version of Frigate", async ({ frigateApp, }) => { await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile(frigateApp.page, importPayload({ version: 99 })); await expect( frigateApp.page.getByText( "Failed to import settings: file requires a newer version of Frigate", ), ).toBeVisible(); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); }); test("rejects a file whose sections are malformed", async ({ frigateApp, }) => { await frigateApp.goto("/settings?page=uiSettings"); // correct type and version, so this only fails at schema validation await chooseImportFile( frigateApp.page, importPayload({ sections: { layouts: "nope", streaming: {}, preferences: {} }, }), ); await expect( frigateApp.page.getByText("Failed to import settings: file is malformed"), ).toBeVisible(); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); }); test("rejects a valid file that carries no settings", async ({ frigateApp, }) => { await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile( frigateApp.page, importPayload({ sections: { layouts: {}, streaming: {}, preferences: {} }, }), ); await expect( frigateApp.page.getByText( "Failed to import settings: file contains no settings", ), ).toBeVisible(); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); }); });