// One-click retry: a failed job that carries a replay descriptor shows a Retry // button that re-runs it (resolving the spec from the on-disk meta sidecar, so // even an evicted/archived job is retryable). A failed job WITHOUT a spec offers // no Retry. Failed jobs are staged as sidecar + log files so the scenario is // deterministic and exercises the sidecar-fallback path directly. import { mkdir, rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, resetData, resolvePath, } from "./helpers"; import { baseUrl } from "./baseUrl"; async function invalidateCache() { await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); } async function makeSlowChannel(slug: string, name: string) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(root, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "youtube", name, url: `https://www.youtube.com/@${slug}/videos`, ytdlpExtraArgs: ["--test-slow"], }), ); } // Stage an archived job on disk: listAllJobs iterates `.log` files and merges // the `.meta.json` sidecar, so both are needed for the row to appear. async function writeArchivedJob( id: string, meta: Record, ): Promise { const dir = resolvePath("test-transcripts/.jobs"); await mkdir(dir, { recursive: true }); await writeFile(`${dir}/${id}.log`, `archived log for ${id}\n`); await writeFile(`${dir}/${id}.meta.json`, JSON.stringify({ id, ...meta })); } test("Retry re-runs a failed job from its spec; spec-less failures offer none", async ({ page, }) => { test.setTimeout(60_000); await resetData(null); await makeSlowChannel("retry-ch", "Retry Ch"); const ts = 1_700_000_000_000; // A failed SYNC carrying a replay spec → retryable. await writeArchivedJob("failedsync1", { kind: "sync", channelSlug: "retry-ch", queueKey: "platform:youtube", status: "failed", queuedAt: ts, startedAt: ts, endedAt: ts + 1000, exitCode: 1, spec: { kind: "sync", slug: "retry-ch" }, }); // A failed job with NO spec → not retryable. await writeArchivedJob("failednospec1", { kind: "import-one", channelSlug: "retry-ch", status: "failed", queuedAt: ts, startedAt: ts, endedAt: ts + 1000, exitCode: 1, }); await invalidateCache(); await page.goto("/jobs"); const failedRow = page.getByRole("row").filter({ hasText: "failedsync1" }); await expect(failedRow.getByRole("button", { name: "Retry" })).toBeVisible(); // The spec-less failed job offers no Retry. const noSpecRow = page.getByRole("row").filter({ hasText: "failednospec1" }); await expect(noSpecRow.getByRole("button", { name: "Retry" })).toHaveCount(0); // Retry re-runs the sync (slow channel → it shows up running). await failedRow.getByRole("button", { name: "Retry" }).click(); const runningSync = page .getByRole("row") .filter({ hasText: "retry-ch" }) .filter({ hasText: "running" }); await expect(runningSync).toBeVisible({ timeout: 15_000 }); }); // A second archived job of a DIFFERENT kind, so the replay table is driven for // more than `sync`. A bucket spec stores only the bucket CATEGORY — never a // frozen video-id list — so retrying it has to re-derive the bucket's current // members. The proof: the video that was partial when the job originally ran is // not the video that is partial now, and the retry acts on the current one. test("Retry on an archived bucket job re-derives the bucket's current members", async ({ page, }) => { test.setTimeout(90_000); const CHANNEL = "test-transcribe"; const ROOT = `test-transcripts/channels/${CHANNEL}`; await resetData("one-transcribe-channel-with-audio"); // The retry flow reads the playlist to resolve each id's URL. Slug-style URLs // keep extractVideoId() === the id without needing platform claim ids. await writeFile( resolvePath(`${ROOT}/playlist`), "https://www.youtube.com/watch?v=vidA\nhttps://www.youtube.com/watch?v=vidB\n", ); // Strand vidB — NOT the vidA the archived job would have run on — so the // partial-downloads bucket currently holds exactly vidB. await rename( resolvePath(`${ROOT}/data/vidB/audio.m4a`), resolvePath(`${ROOT}/data/vidB/audio.m4a.part`), ); await generateReport(page, CHANNEL); // The partial-downloads bucket lives on the Download stage, and the channel // page shows one stage at a time. await page.goto(channelStage(CHANNEL, "download")); await expect( page.getByRole("heading", { name: /Partial downloads \(1\)/ }), ).toBeVisible(); const ts = 1_700_000_000_000; await writeArchivedJob("failedbucket1", { kind: "retry-bucket", channelSlug: CHANNEL, queueKey: "platform:youtube", status: "failed", queuedAt: ts, startedAt: ts, endedAt: ts + 1000, exitCode: 1, spec: { kind: "retry-bucket", slug: CHANNEL, bucket: "partialDownloads" }, }); await invalidateCache(); await page.goto("/jobs"); const bucketRow = page.getByRole("row").filter({ hasText: "failedbucket1" }); await expect(bucketRow.getByRole("button", { name: "Retry" })).toBeVisible(); await bucketRow.getByRole("button", { name: "Retry" }).click(); // vidB (the CURRENT bucket member) gets its download resumed. await expect .poll(() => pathExists(`${ROOT}/data/vidB/audio.m4a`), { timeout: 45_000, }) .toBe(true); });