/** * Config Editor tests -- MEDIUM tier. * * Monaco load + value, Save (config/save?save_option=saveonly), * Save error path, Save and Restart (WS frame via useRestart), * Copy (clipboard), schema markers. */ import { test, expect } from "../fixtures/frigate-test"; import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames"; import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard"; import { getMonacoVisibleText, replaceMonacoValue, waitForErrorMarker, } from "../helpers/monaco"; const SAMPLE_CONFIG = "mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n"; async function installSaveRoute( app: { page: import("@playwright/test").Page }, status: number, body: Record, ): Promise<{ capturedUrl: () => string | null; capturedBody: () => string | null; }> { let lastUrl: string | null = null; let lastBody: string | null = null; await app.page.route("**/api/config/save**", async (route) => { lastUrl = route.request().url(); lastBody = route.request().postData(); await route.fulfill({ status, json: body }); }); return { capturedUrl: () => lastUrl, capturedBody: () => lastBody, }; } test.describe("Config Editor — Monaco @medium", () => { test("editor loads with mocked configRaw content", async ({ frigateApp }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); // Assert via DOM-rendered visible text (Monaco virtualizes — works // for short configs which covers our mocked content). await expect .poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 }) .toContain("front_door"); }); }); test.describe("Config Editor — Save @medium", () => { test.skip( ({ frigateApp }) => frigateApp.isMobile, "Save button copy is desktop-visible (hidden md:block)", ); test("clicking Save Only POSTs config/save?save_option=saveonly", async ({ frigateApp, }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); const capture = await installSaveRoute(frigateApp, 200, { message: "Config saved", }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); await frigateApp.page.getByLabel("Save Only").click(); await expect .poll(() => capture.capturedUrl(), { timeout: 5_000 }) .toMatch(/config\/save\?save_option=saveonly/); // Body is the raw YAML as text/plain await expect .poll(() => capture.capturedBody(), { timeout: 5_000 }) .toContain("front_door"); }); test("Save error shows the server message in the error area", async ({ frigateApp, }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await installSaveRoute(frigateApp, 400, { message: "Invalid field `cameras.front_door`", }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); await frigateApp.page.getByLabel("Save Only").click(); await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({ timeout: 5_000, }); }); }); test.describe("Config Editor — Save and Restart @medium", () => { test.skip( ({ frigateApp }) => frigateApp.isMobile, "Save and Restart button copy is desktop-visible", ); test("Save and Restart opens dialog; confirm sends WS restart frame", async ({ frigateApp, }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await installSaveRoute(frigateApp, 200, { message: "Saved" }); await installWsFrameCapture(frigateApp.page); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); await frigateApp.page.getByLabel("Save & Restart").click(); const dialog = frigateApp.page.getByRole("alertdialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); await dialog.getByRole("button", { name: /restart/i }).click(); await waitForWsFrame( frigateApp.page, (frame) => frame.includes('"restart"') || frame.includes("restart"), { message: "useRestart should send a WS frame on the restart topic" }, ); }); test("cancelling the restart dialog leaves body interactive", async ({ frigateApp, }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await installSaveRoute(frigateApp, 200, { message: "Saved" }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); await frigateApp.page.getByLabel("Save & Restart").click(); const dialog = frigateApp.page.getByRole("alertdialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); await dialog.getByRole("button", { name: /cancel/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 3_000 }); await expect( frigateApp.page.locator(".monaco-editor").first(), ).toBeVisible(); }); }); test.describe("Config Editor — Copy @medium", () => { test.skip( ({ frigateApp }) => frigateApp.isMobile, "Copy button copy is desktop-visible", ); test("Copy places the editor value in the clipboard", async ({ frigateApp, context, }) => { await grantClipboardPermissions(context); await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); await frigateApp.page.getByLabel("Copy Config").click(); await expect .poll(() => readClipboard(frigateApp.page), { timeout: 5_000 }) .toContain("front_door"); }); }); test.describe("Config Editor — schema markers @medium", () => { test.skip( ({ frigateApp }) => frigateApp.isMobile, "Schema validation assumes focused desktop editing", ); test("invalid YAML renders at least one error marker in the DOM", async ({ frigateApp, }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); // Replace editor contents with clearly invalid YAML via keyboard. await replaceMonacoValue( frigateApp.page, "this is not: [yaml: and has {unbalanced", ); // Monaco debounces marker evaluation; the .squiggly-error decoration // appears asynchronously in the .view-overlays layer. await waitForErrorMarker(frigateApp.page); }); }); test.describe("Config Editor — Cmd+S keyboard shortcut @medium", () => { test.skip( ({ frigateApp }) => frigateApp.isMobile, "Keyboard save shortcut is desktop-only", ); test("Cmd/Ctrl+S fires the same config/save POST as the Save button", async ({ frigateApp, }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); const capture = await installSaveRoute(frigateApp, 200, { message: "Saved", }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); // Focus the editor so Monaco's keybinding receives the shortcut. await frigateApp.page.locator(".monaco-editor").first().click(); await frigateApp.page.keyboard.press("ControlOrMeta+s"); await expect .poll(() => capture.capturedUrl(), { timeout: 5_000 }) .toMatch(/config\/save\?save_option=saveonly/); }); }); test.describe("Config Editor — Safe Mode auto-validation @medium", () => { test("safe-mode config auto-posts on mount and shows the inline error", async ({ frigateApp, }) => { // Thread safe_mode: true through the config override, then stub // config/save to return a validation error. The page's // initialValidationRef effect runs on mount and POSTs // config/save?save_option=saveonly with the raw config; the 400 // surfaces through setError. // installDefaults must come first so our specific route wins (LIFO). await frigateApp.installDefaults({ config: { safe_mode: true } as unknown as Record, configRaw: "cameras:\n front_door:\n ffmpeg: {}\n", }); let autoSaveCalled = false; await frigateApp.page.route("**/api/config/save**", async (route) => { autoSaveCalled = true; await route.fulfill({ status: 400, json: { message: "safe-mode validation failure" }, }); }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); await expect.poll(() => autoSaveCalled, { timeout: 10_000 }).toBe(true); await expect( frigateApp.page.getByText(/safe-mode validation failure/i), ).toBeVisible({ timeout: 5_000 }); }); }); test.describe("Config Editor — mobile @medium @mobile", () => { test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); test("editor renders at narrow viewport", async ({ frigateApp }) => { await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await frigateApp.goto("/config"); await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( { timeout: 15_000 }, ); }); });