// The Workers page: live per-worker status plus the runtime enable/disable/drain // controls and the temporary pause-all/resume-all. These are transient operator // overrides on the global worker pool — they don't touch settings.json — plus // the persisted worker list, which is configured here now. import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, readJson, resetData, resolvePath, writeSettings, } from "./helpers"; import { baseUrl } from "./baseUrl"; // Reset the in-memory worker pool (and job registry) without restarting the // dev server — the next page load re-seeds the pool from settings + the saved // default, exactly as a real relaunch would. The editor's test-only endpoint // nulls the globalThis pool singleton. async function relaunchPool() { await fetch(`${baseUrl}/api/test/invalidate-cache`); } async function makeTranscribeChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(root, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "transcribe", name: slug, url: "https://odysee.com/@example", audioFormat: "mp3", }), ); for (const id of ids) { await mkdir(`${root}/data/${id}`, { recursive: true }); await writeFile(`${root}/data/${id}/audio.mp3`, `fake audio ${id}\n`); } } async function transcriptCount(slug: string, ids: string[]): Promise { let n = 0; for (const id of ids) { if ( await pathExists(`test-transcripts/channels/${slug}/data/${id}/transcript.json`) ) { n++; } } return n; } // Two enabled local workers (one slot each) so disabling one still leaves the // system running. const TWO_WORKERS = { workers: [ { id: "gpu", name: "GPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 1, appId: "whisper-cpp", config: {} }, ], }; test.beforeEach(async () => { await resetData("empty"); }); test("lists configured workers", async ({ page }) => { await writeSettings(TWO_WORKERS); await page.goto("/workers"); const gpu = page.getByRole("listitem").filter({ hasText: "GPU" }); const cpu = page.getByRole("listitem").filter({ hasText: "CPU" }); await expect(gpu).toContainText("idle"); await expect(cpu).toContainText("idle"); }); test("disable then enable a worker flips its state", async ({ page }) => { await writeSettings(TWO_WORKERS); await page.goto("/workers"); const gpu = page.getByRole("listitem").filter({ hasText: "GPU" }); await gpu.getByRole("button", { name: "disable GPU" }).click(); await expect(gpu).toContainText("disabled"); await gpu.getByRole("button", { name: "enable GPU" }).click(); await expect(gpu).toContainText(/idle|busy/); }); test("pause all disables every worker; resume restores them", async ({ page, }) => { await writeSettings(TWO_WORKERS); await page.goto("/workers"); // Put one worker into a non-default state first so resume must restore it. const gpu = page.getByRole("listitem").filter({ hasText: "GPU" }); await gpu.getByRole("button", { name: "disable GPU" }).click(); await expect(gpu).toContainText("disabled"); await page.getByRole("button", { name: "Pause Transcriptions" }).click(); await expect(page.getByRole("status")).toContainText(/paused/i); const cpu = page.getByRole("listitem").filter({ hasText: "CPU" }); await expect(cpu).toContainText("disabled"); await page.getByRole("button", { name: "Resume Transcriptions" }).click(); // CPU returns to enabled (idle); GPU stays disabled — its pre-pause state. await expect(cpu).toContainText(/idle|busy/); await expect(gpu).toContainText("disabled"); }); test("pausing all workers pauses a running batch instead of failing it; resume completes it", async ({ page, }) => { test.setTimeout(90_000); // One worker, one slot, so videos transcribe one at a time and parking is easy // to observe. The fake whisper runs slowly for ids containing "slowop". await writeSettings({ workers: [ { id: "only", name: "Only", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, ], }); const ids = ["slowop1", "slowop2", "slowop3"]; await makeTranscribeChannel("pause-batch", ids); await generateReport(page, "pause-batch"); await page.goto(channelStage("pause-batch", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait until the batch is running with its first transcription in flight. await page.goto("/jobs"); await expect( page.getByRole("progressbar", { name: /Transcribing slowop/ }).first(), ).toBeVisible({ timeout: 15_000 }); // Pause all workers. The in-flight one finishes (disable lets it complete) but // no new transcription starts — the batch parks, it does NOT fail. await page.goto("/workers"); await page.getByRole("button", { name: "Pause Transcriptions" }).click(); await expect(page.getByRole("status")).toContainText(/paused/i); // Give it time: at most the in-flight transcript lands, then progress stalls. await page.waitForTimeout(6_000); const whilePaused = await transcriptCount("pause-batch", ids); expect(whilePaused).toBeLessThan(ids.length); // The batch is still running (paused), not failed/done: /jobs lists it live. await page.goto("/jobs"); const row = page .getByRole("row") .filter({ hasText: "pause-batch" }) .filter({ hasText: "Transcribe all" }) .first(); await expect(row.getByText("running", { exact: true })).toBeVisible({ timeout: 10_000, }); // Resume → the remaining videos transcribe and the batch finishes. await page.goto("/workers"); await page.getByRole("button", { name: "Resume Transcriptions" }).click(); await expect .poll(() => transcriptCount("pause-batch", ids), { timeout: 60_000 }) .toBe(ids.length); }); test("Workers page and Active jobs cross-reference the running task", async ({ page, }) => { test.setTimeout(60_000); await writeSettings({ workers: [ { id: "only", name: "Only", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, ], }); await makeTranscribeChannel("xref-chan", ["slowopx1"]); await generateReport(page, "xref-chan"); await page.goto(channelStage("xref-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The Workers page shows the worker actively transcribing the video, with the // channel it belongs to. await page.goto("/workers"); const only = page.getByRole("listitem").filter({ hasText: "Only" }); await expect(only).toContainText("Transcribing", { timeout: 15_000 }); await expect(only).toContainText("slowopx1"); await expect(only).toContainText("xref-chan"); // /jobs shows that task running ON the worker "Only". await page.goto("/jobs"); const row = page .getByRole("row") .filter({ hasText: "xref-chan" }) .filter({ hasText: "Transcribe all" }) .first(); await expect(row).toContainText(/on\s+Only/, { timeout: 15_000 }); }); test("Set as default persists the enabled workers and re-applies them on relaunch", async ({ page, }) => { await writeSettings(TWO_WORKERS); await page.goto("/workers"); const gpu = page.getByRole("listitem").filter({ hasText: "GPU" }); const cpu = page.getByRole("listitem").filter({ hasText: "CPU" }); // Disable CPU, then save the current arrangement (only GPU on) as the default. await cpu.getByRole("button", { name: "disable CPU" }).click(); await expect(cpu).toContainText("disabled"); await page.getByRole("button", { name: /^set as default$/i }).click(); await expect( page.getByRole("status").filter({ hasText: /saved as default/i }), ).toBeVisible(); // Persisted to the worker-defaults file as just the GPU id. const saved = await readJson<{ enabledWorkerIds: string[] }>( "test-transcripts/.workers/defaults.json", ); expect(saved.enabledWorkerIds).toEqual(["gpu"]); // The button now reflects an existing default and GPU is badged. await expect( page.getByRole("button", { name: /update default/i }), ).toBeVisible(); await expect(gpu).toContainText("default"); // Re-enable CPU at runtime so the live state differs from the saved default. await cpu.getByRole("button", { name: "enable CPU" }).click(); await expect(cpu).toContainText(/idle|busy/); // Relaunch: the saved default is applied — GPU enabled, CPU disabled (its // runtime re-enable is discarded because it's not in the default). await relaunchPool(); await page.goto("/workers"); await expect(gpu).toContainText(/idle|busy/); await expect(cpu).toContainText("disabled"); }); test("a worker not in the saved default starts disabled on the next launch", async ({ page, }) => { await writeSettings(TWO_WORKERS); await page.goto("/workers"); // Save the default with both workers enabled. await page.getByRole("button", { name: /^set as default$/i }).click(); await expect( page.getByRole("status").filter({ hasText: /saved as default/i }), ).toBeVisible(); // Add a third worker in Settings (enabled there), then relaunch. writeSettings // resets the pool, so the next load re-seeds from settings + the saved default. await writeSettings({ workers: [ ...TWO_WORKERS.workers, { id: "extra", name: "Extra", kind: "local", enabled: true, priority: 2, appId: "whisper-cpp", config: {} }, ], }); await page.goto("/workers"); const gpu = page.getByRole("listitem").filter({ hasText: "GPU" }); const extra = page.getByRole("listitem").filter({ hasText: "Extra" }); // GPU was in the default → enabled; Extra wasn't → disabled despite being // enabled in settings.json. await expect(gpu).toContainText(/idle|busy/); await expect(extra).toContainText("disabled"); }); // --- The persisted worker list, configured here ------------------------------ // // Moved from settings.spec.ts: the list was a fieldset in the one big settings // form and is now its own form on this page, beside the live workers it // describes. Every worker aria-label is byte-identical; the page and the save // button are what changed. The file-level beforeEach already does the // resetData("empty") these tests used to bring with them. test.describe("worker configuration", () => { test("shows the migrated default workers and persists an added worker", async ({ page, }) => { await page.goto("/workers"); const form = page.locator('form[data-settings-block="workers"]'); // An empty settings.json migrates to one whisper-cpp worker PER slot — the old // default parallelTranscriptions of 2 becomes two enabled whisper workers. await expect(form.getByLabel("worker 1 name")).toHaveValue(/whisper/i); await expect(form.getByLabel("worker 2 name")).toHaveValue(/whisper/i); // Add a third worker on a different engine. await form.getByRole("button", { name: "+ Local worker" }).click(); await form.getByLabel("worker 3 name").fill("CPU chough"); await form.getByLabel("worker 3 engine").selectOption("chough"); await form.getByRole("button", { name: "Save workers" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); const saved = await readJson<{ workers?: { name: string; appId?: string; priority: number }[]; }>("test-settings.json"); expect(saved.workers).toHaveLength(3); expect(saved.workers?.[2].name).toBe("CPU chough"); expect(saved.workers?.[2].appId).toBe("chough"); // List order is priority. expect(saved.workers?.[2].priority).toBe(2); await page.reload(); await expect(form.getByLabel("worker 3 name")).toHaveValue("CPU chough"); }); test("copy duplicates a worker into a new slot", async ({ page }) => { await page.goto("/workers"); const form = page.locator('form[data-settings-block="workers"]'); // Configure worker 1 as a chough-server worker, then copy it — the pattern for // running two togglable slots against one chough --server. await form.getByLabel("worker 1 engine").selectOption("chough"); await form.getByLabel("worker 1 name").fill("Chough server"); await form.getByLabel(/chough server URL/i).fill("http://localhost:8165"); await form.getByRole("button", { name: "copy worker 1" }).click(); await expect(form.getByLabel("worker 2 name")).toHaveValue("Chough server (copy)"); await form.getByRole("button", { name: "Save workers" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); const saved = await readJson<{ workers?: { appId?: string; config?: { remoteUrl?: string } }[]; }>("test-settings.json"); const choughs = (saved.workers ?? []).filter((w) => w.appId === "chough"); expect(choughs.length).toBe(2); expect(choughs.every((w) => w.config?.remoteUrl === "http://localhost:8165")).toBe( true, ); }); test("rejects saving with no enabled workers", async ({ page }) => { await page.goto("/workers"); const form = page.locator('form[data-settings-block="workers"]'); // Migration produced two enabled workers; disable both. await form.getByLabel("worker 1 enabled").uncheck(); await form.getByLabel("worker 2 enabled").uncheck(); await form.getByRole("button", { name: "Save workers" }).click(); await expect(form.getByRole("alert")).toContainText( /at least one worker must be enabled/i, ); }); });