import { test, expect } from "@playwright/test"; import { readJson, resetData } from "./helpers"; // PER-OPERATION SETTINGS, on the operation's own page. // // Slice 3 moved four fieldsets off /settings — Digest, Diarization, the speaker // work LANE and Speaker attribution — onto /operations/, each with its own //
and its own action. That split is what retired the hidden // `*FormPresent` markers: while one form saved everything, an unchecked // checkbox (absent from a FormData) submitted by a form lacking the block would // have read every switch as off, so each block needed a marker to prove its // fields were on the page. Separate forms make that structural instead. // // Every field NAME and every legend is unchanged from the old fieldsets, so // these are the same assertions against a different route — except the last // two, which cover blocks that had no form-driving spec at all before. test.beforeEach(async () => { await resetData("empty"); }); test("digest settings round-trip through the form", async ({ page }) => { await page.goto("/operations/digest"); const form = page.locator('form[data-settings-block="digest"]'); await form.getByLabel("Timestamp mode").selectOption("absolute"); await form.getByLabel(/prompt variant label/i).fill("bakeoff-r3"); await form.getByLabel(/spend cap/i).fill("12.5"); await form.getByLabel(/long-tail cutoff/i).fill("7200"); await form.getByRole("checkbox", { name: /enable the metered/i }).check(); // Per-app numCtx rides in the hidden digestAppsJson payload. await form.getByText("Per-engine configuration").click(); await form .getByLabel("Context window (num_ctx) for ollama-direct") .fill("4096"); await form.getByRole("button", { name: "Save digest settings" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); const saved = await readJson<{ digest?: { timestampMode: string; promptVariant: string; spendCapUsd: number; longTailSeconds: number; remoteEnabled: boolean; sections: string[]; apps: Record; }; }>("test-settings.json"); expect(saved.digest?.timestampMode).toBe("absolute"); expect(saved.digest?.promptVariant).toBe("bakeoff-r3"); expect(saved.digest?.spendCapUsd).toBe(12.5); expect(saved.digest?.longTailSeconds).toBe(7200); expect(saved.digest?.remoteEnabled).toBe(true); expect(saved.digest?.apps["ollama-direct"]?.numCtx).toBe(4096); // Never left empty — an empty section list would generate nothing. expect(saved.digest?.sections.length).toBeGreaterThan(0); }); test("a save on /settings does not reset the digest prompt shape", async ({ page, }) => { // THE BUG THIS EXISTS FOR, and it is now a genuinely CROSS-FORM test: the // digest action rebuilds the whole block on every save, so any // freshness-affecting field it fails to carry through is silently reset — and // timestampMode/promptVariant are folded into the recorded identity, so a // reset would invalidate every digest generated under the non-default shape // while looking like a no-op. What used to protect the block on an unrelated // save was a hidden marker; what protects it now is that the unrelated save // happens in a different form on a different page and reads // `getSettings().digest` verbatim. await page.goto("/operations/digest"); const digest = page.locator('form[data-settings-block="digest"]'); await digest.getByLabel("Timestamp mode").selectOption("absolute"); await digest.getByLabel(/prompt variant label/i).fill("keepme"); await digest.getByRole("button", { name: "Save digest settings" }).click(); await expect(digest.getByRole("status")).toHaveText("Saved."); // Reload rather than submitting straight again: React 19 resets // inputs to the defaultValue of the render they were in, so a second submit // from the same render would re-post the digest values from BEFORE the first // save and prove nothing. A reload is also the real scenario — the operator // sets the digest up, comes back later, and changes something unrelated. await page.reload(); await expect(digest.getByLabel("Timestamp mode")).toHaveValue("absolute"); await page.goto("/settings"); await page.getByLabel(/admin title/i).fill("Unrelated Change"); await page.getByRole("button", { name: /save settings/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const saved = await readJson<{ adminTitle: string; digest?: { timestampMode: string; promptVariant: string }; }>("test-settings.json"); expect(saved.adminTitle).toBe("Unrelated Change"); expect(saved.digest?.timestampMode).toBe("absolute"); expect(saved.digest?.promptVariant).toBe("keepme"); }); test("the lane's switch survives a save of the operation form beside it", async ({ page, }) => { // THE PROPERTY THE MARKERS USED TO PROTECT, proven structurally. Two forms // sit on this one page — the diarization block and the shared backfill LANE // block — and `backfillEnabled` is a checkbox, so it is absent from the // diarization form's FormData entirely. Before the split, one FormData // carried both and only a hidden marker stopped the second from reading the // first's absent checkbox as off. await page.goto("/operations/diarization"); const lane = page.locator('form[data-settings-block="backfill"]'); await lane.getByRole("checkbox", { name: "Run the backfill lane" }).check(); await lane.getByRole("button", { name: "Save lane settings" }).click(); await expect(lane.getByRole("status")).toHaveText("Saved."); const diarization = page.locator('form[data-settings-block="diarization"]'); await diarization .getByRole("checkbox", { name: "Capture speaker diarization" }) .check(); await diarization .getByRole("button", { name: "Save diarization settings" }) .click(); await expect(diarization.getByRole("status")).toHaveText("Saved."); // "Run the backfill lane" IS the lane's gate, and since slice 1.4 the gate is // `autoQueue.backfill.held` — checked means running, so held is false. It used // to write the inverted `backfill.enabled`; S0-pause deleted that field, so // there is no second place a click could land. const saved = await readJson<{ autoQueue?: { backfill?: { held?: boolean } }; diarization?: { enabled: boolean }; }>("test-settings.json"); expect(saved.autoQueue?.backfill?.held).toBe(false); expect(saved.diarization?.enabled).toBe(true); }); // THE BUG THIS EXISTS FOR: the sortformer engine shipped with its settings // reachable only by hand-editing settings.json, because the form simply had no // controls for them. A field the form does not render is a field an operator // cannot set — and, worse, the diarization action rebuilds the whole block on // save, so a control added to the markup but forgotten in the action would look // like it worked and silently revert. test("the sortformer engine settings round-trip through the form", async ({ page, }) => { await page.goto("/operations/diarization"); const form = page.locator('form[data-settings-block="diarization"]'); // Targeted by NAME, not by label. getByLabel matches by substring and these // labels wrap their whole explanatory hint, so the accessible name is a // paragraph — "Engine" matches nothing exactly and matches "Engine threads" // loosely. The control's name is the thing the action actually reads. await form.locator('select[name="diarizationEngine"]').selectOption("sortformer"); await form.locator('select[name="diarizationBackend"]').selectOption("cpu"); await form .getByLabel("Sortformer engine binary") .fill("/opt/sortformer/diarize-file"); await form .getByLabel("Sortformer model (GGUF)") .fill("/opt/sortformer/model.gguf"); await form.getByRole("button", { name: "Save diarization settings" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); const saved = await readJson<{ diarization?: { engine: string; backend: string; sortformerBin: string; sortformerModel: string; segModel: string; threshold: number; }; }>("test-settings.json"); expect(saved.diarization?.engine).toBe("sortformer"); expect(saved.diarization?.backend).toBe("cpu"); expect(saved.diarization?.sortformerBin).toBe("/opt/sortformer/diarize-file"); expect(saved.diarization?.sortformerModel).toBe("/opt/sortformer/model.gguf"); // The sherpa fields are still carried, not clobbered by selecting the other // engine — switching back must not mean re-entering three paths. expect(saved.diarization?.threshold).toBe(0.9); }); // The attribution block had NO form-driving spec at all before slice 3, which // is how the most expensive switch in the console — textOnlyEnabled, ~194,000 // model calls across this corpus — went untested from a browser. test("attribution settings round-trip on the text-only operation's page", async ({ page, }) => { await page.goto("/operations/attribution-text"); const form = page.locator('form[data-settings-block="attribution"]'); await form .getByRole("checkbox", { name: "Enable speaker attribution" }) .check(); await form .getByRole("checkbox", { name: "Guess the speakers from the transcript" }) .check(); await form.getByLabel("Model override").fill("qwen2.5:14b"); await form.getByRole("button", { name: "Save attribution settings" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); const saved = await readJson<{ attribution?: { enabled: boolean; textOnlyEnabled: boolean; model: string; }; }>("test-settings.json"); expect(saved.attribution?.enabled).toBe(true); expect(saved.attribution?.textOnlyEnabled).toBe(true); expect(saved.attribution?.model).toBe("qwen2.5:14b"); }); test("both attribution operations draw the same block, and the lane's", async ({ page, }) => { // ONE BLOCK, TWO OPERATIONS: both declare settingsBlock "attribution", so // both pages render this form and either one saves the same fields. The lane // form is beside it on both, because all three speaker operations share // BACKFILL_QUEUE. await page.goto("/operations/attribution-diarized"); await expect( page.locator('form[data-settings-block="attribution"]'), ).toBeVisible(); await expect( page.getByRole("group", { name: "Speaker attribution" }), ).toBeVisible(); await expect( page.getByRole("group", { name: "Speaker work lane" }), ).toBeVisible(); }); test("an operation with no settings block renders no settings form", async ({ page, }) => { // The switch is over the DESCRIPTOR's block, so an operation that declares // none gets nothing — not an empty fieldset, and not another operation's. await page.goto("/operations/download"); await expect(page.locator("form[data-settings-block]")).toHaveCount(0); });