Files
frigate/web/e2e/specs/settings/ui-settings-transfer.spec.ts
Josh HawkinsandGitHub 64d6366ac4 Live streaming tech selection (#24374)
* allow users to select live streaming technology

* fix webrtc being downgraded to mse on load

`useUserPersistence` seeds state with the default and loads asynchronously, so the first render always used `mse` instead of the saved choice, and `useWebRTCGloballyAvailable` reports `checking` until the probe settles and re-enters that state on every consumer mount, so a saved `webrtc` was rewritten to `mse` even after the probe had already passed. On Safari the MSE player then timed out and latched the jsmpeg fallback. A pending probe now counts as available, the player waits on `autoLive` until the stored preferences load, and `handleError` gates on the mode in use since the fallback flag no longer implies webrtc is untried. A rejected IndexedDB read also resolves `loaded` now, so a blocked store can't leave the player waiting forever.

* add support for configurable ICE servers in WebRTC player

* add mic error state, fix dialog overwriting saved choice and dashboard ignoring stream

* tweaks
2026-09-16 09:12:06 -05:00

522 lines
17 KiB
TypeScript

/**
* 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";
const 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<string, unknown>) {
await page.evaluate(async (data) => {
await new Promise<void>((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<string, unknown> = {}) {
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<void>((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("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();
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
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,
}) => {
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(
OUTDOOR_LAYOUT,
);
});
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(
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();
});
});