// Remote-worker protocol. The test server runs with WORKER_TOKEN set (see // package.json dev:test), so its /api/worker/* endpoints are live. Two angles: // 1. The acceptor API directly (auth + a raw upload→poll→result round-trip). // 2. A full end-to-end where a worker points at the instance's OWN base URL. // A worker is one slot, so the outer lease holds the remote worker's slot // while the acceptor's internal transcribe falls to the local worker — no // delegation loop — exercising the whole client→HTTP→server→pool→result path. import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, resetData, resolvePath, writeSettings, } from "./helpers"; import { baseUrl } from "./baseUrl"; const TOKEN = "test-worker-token"; 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`); } } test.beforeEach(async () => { await resetData("empty"); }); test("worker health endpoint enforces the bearer token", async ({ request }) => { const noAuth = await request.get(`${baseUrl}/api/worker/health`); expect(noAuth.status()).toBe(401); const wrong = await request.get(`${baseUrl}/api/worker/health`, { headers: { authorization: "Bearer nope" }, }); expect(wrong.status()).toBe(401); const ok = await request.get(`${baseUrl}/api/worker/health`, { headers: { authorization: `Bearer ${TOKEN}` }, }); expect(ok.status()).toBe(200); const body = await ok.json(); expect(body.ok).toBe(true); expect(Array.isArray(body.workers)).toBe(true); }); test("acceptor transcribes an uploaded audio and returns the result", async ({ request, }) => { // One local worker so the acceptor has something to run the upload on. await writeSettings({ workers: [ { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, ], }); const post = await request.post(`${baseUrl}/api/worker/transcribe`, { headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/octet-stream", "x-audio-name": "audio.mp3", "x-job-label": "uploaded1", }, data: Buffer.from("fake audio uploaded1\n"), }); expect(post.status()).toBe(202); const { remoteJobId } = await post.json(); expect(remoteJobId).toBeTruthy(); // Poll events until done. await expect .poll( async () => { const r = await request.get( `${baseUrl}/api/worker/transcribe/${remoteJobId}/events`, { headers: { authorization: `Bearer ${TOKEN}` } }, ); const b = await r.json(); return b.status; }, { timeout: 30_000 }, ) .toBe("done"); const result = await request.get( `${baseUrl}/api/worker/transcribe/${remoteJobId}/result`, { headers: { authorization: `Bearer ${TOKEN}` } }, ); expect(result.status()).toBe(200); const transcript = await result.json(); // fake-whisper emits a whisper-json doc with a transcription array. expect(transcript).toHaveProperty("transcription"); // Cleanup removes the scratch; result then 404s. const del = await request.delete( `${baseUrl}/api/worker/transcribe/${remoteJobId}`, { headers: { authorization: `Bearer ${TOKEN}` } }, ); expect(del.status()).toBe(200); const gone = await request.get( `${baseUrl}/api/worker/transcribe/${remoteJobId}/result`, { headers: { authorization: `Bearer ${TOKEN}` } }, ); expect(gone.status()).toBe(404); }); test("a batch dispatched to a remote worker transcribes via the HTTP round-trip", async ({ page, }) => { test.setTimeout(60_000); // Remote worker (priority 0) points at THIS instance; a local worker backs the // acceptor. The remote worker's single slot is held by the outer lease while // the acceptor runs, so its internal transcribe uses the local worker — no loop. await writeSettings({ workers: [ { id: "self", name: "Self Remote", kind: "remote", enabled: true, priority: 0, remote: { baseUrl, token: TOKEN } }, { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 1, appId: "whisper-cpp", config: {} }, ], }); await makeTranscribeChannel("remote-chan", ["vidremote1"]); await generateReport(page, "remote-chan"); await page.goto(channelStage("remote-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The job log proves the video was dispatched over the remote path. await expect( page.getByLabel("Transcribe missing output"), ).toContainText("via remote", { timeout: 30_000 }); // And the transcript lands locally, pulled back from the remote. await expect .poll( () => pathExists( "test-transcripts/channels/remote-chan/data/vidremote1/transcript.json", ), { timeout: 30_000 }, ) .toBe(true); }); test("an unreachable remote is auto-disabled and the video fails over to a local worker", async ({ page, }) => { test.setTimeout(60_000); // Remote (priority 0) points at a dead port; a local worker backs it up. await writeSettings({ workers: [ { id: "dead", name: "Dead Remote", kind: "remote", enabled: true, priority: 0, remote: { baseUrl: "http://127.0.0.1:59599", token: TOKEN } }, { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 1, appId: "whisper-cpp", config: {} }, ], }); await makeTranscribeChannel("failover-chan", ["vidfo1"]); await generateReport(page, "failover-chan"); await page.goto(channelStage("failover-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The video is still transcribed — on the local worker, after a health check // confirms the remote is down and degrades it. await expect .poll( () => pathExists( "test-transcripts/channels/failover-chan/data/vidfo1/transcript.json", ), { timeout: 30_000 }, ) .toBe(true); // The Workers page shows the dead remote as degraded. await page.goto("/workers"); const dead = page.getByRole("listitem").filter({ hasText: "Dead Remote" }); await expect(dead).toContainText("degraded", { timeout: 10_000 }); });