import { mkdir, readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, readJson, resetData, resolvePath, writeSettings, } from "./helpers"; // Configurable cookies-from-browser: global + per-channel value, and a cookie // MODE (always / when-required / defer). The fake yt-dlp's `cookiegated` // sentinel fails any metadata/download invocation without // --cookies-from-browser (yt-dlp's age-gate error) and succeeds with it, and // every relevant invocation line logs `cookies=` so flag presence can // be asserted per spawn. const CHANNEL = "test-cookies"; const FIXTURE = "cookies-mode-channel"; const ROOT = `test-transcripts/channels/${CHANNEL}`; const COOKIES = "firefox:test"; const here = path.dirname(fileURLToPath(import.meta.url)); type DownloadOutcome = { status: string; attempts: Array<{ kind: string; usedCookies: boolean }>; }; type Snapshot = { buckets?: { needsCookies?: string[] }; }; async function defaultSettings(): Promise> { const raw = await readFile( path.join(here, "fixtures", "test-settings.default.json"), "utf8", ); return JSON.parse(raw) as Record; } async function readInvocations(): Promise { try { return await readFile( path.join(here, "..", ROOT, "fake-ytdlp.invocations"), "utf8", ); } catch { return ""; } } // The download job arms a debounced (~1s) snapshot regen; poll snapshot.json // until the needsCookies bucket reaches the expected size before reloading. async function waitForNeedsCookiesBucket(size: number): Promise { await expect .poll( async () => { const snap = await readJson(`${ROOT}/snapshot.json`).catch( () => null, ); return snap?.buckets?.needsCookies?.length ?? -1; }, { timeout: 15_000 }, ) .toBe(size); } test("settings: cookie value + mode round-trip through the form", async ({ page, }) => { await resetData(FIXTURE); await page.goto("/settings"); await page.getByLabel(/^cookies from browser/i).fill(COOKIES); // The value field's hint mentions "cookie mode", so a label lookup is // ambiguous — target the select by name. const modeSelect = page.locator('select[name="cookieMode"]'); await modeSelect.selectOption("always"); await page.getByRole("button", { name: /save settings/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const saved = await readJson<{ cookiesFromBrowser?: string; cookieMode?: string; }>("test-settings.json"); expect(saved.cookiesFromBrowser).toBe(COOKIES); expect(saved.cookieMode).toBe("always"); await page.reload(); await expect(page.getByLabel(/^cookies from browser/i)).toHaveValue(COOKIES); await expect(modeSelect).toHaveValue("always"); }); test("channel form: overrides persist and clear back to inherit", async ({ page, }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "configure")); await page.locator("summary").filter({ hasText: "Advanced" }).click(); await page.getByLabel(/^cookies from browser/i).fill("chrome:Profile 1"); await page.getByLabel("cookie mode").selectOption("defer"); await page.getByRole("button", { name: /save changes/i }).click(); await expect .poll(async () => readJson<{ cookiesFromBrowser?: string; cookieMode?: string }>( `${ROOT}/config.json`, ), ) .toMatchObject({ cookiesFromBrowser: "chrome:Profile 1", cookieMode: "defer", }); // Clear both back to inherit. CHANNEL_FORM_FIELDS layering must actually // remove the stored values, not re-layer the stale ones. await page.reload(); await page.locator("summary").filter({ hasText: "Advanced" }).click(); const cookiesField = page.getByLabel(/^cookies from browser/i); await expect(cookiesField).toHaveValue("chrome:Profile 1"); await cookiesField.fill(""); await page.getByLabel("cookie mode").selectOption(""); await page.getByRole("button", { name: /save changes/i }).click(); await expect .poll(async () => { const config = await readJson>( `${ROOT}/config.json`, ); return { cookiesFromBrowser: config.cookiesFromBrowser, cookieMode: config.cookieMode, }; }) .toEqual({ cookiesFromBrowser: undefined, cookieMode: undefined }); }); test("always mode: every yt-dlp invocation carries the cookie value", async ({ page, }) => { test.setTimeout(120_000); await resetData(FIXTURE); await writeSettings({ ...(await defaultSettings()), cookiesFromBrowser: COOKIES, cookieMode: "always", }); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( "Managed download complete: 2 succeeded, 0 failed", { timeout: 60_000 }, ); const invocations = await readInvocations(); expect(invocations).toMatch( new RegExp(`prefetch:.*vidcookiegated1 cookies=${COOKIES}`), ); expect(invocations).toMatch( new RegExp(`prefetch:.*normalvideo1 cookies=${COOKIES}`), ); expect(invocations).toMatch( new RegExp(`youtube-single:.*vidcookiegated1 cookies=${COOKIES}`), ); expect(invocations).toMatch( new RegExp(`youtube-single:.*normalvideo1 cookies=${COOKIES}`), ); // No cookie-less invocation lines at all in always mode. expect(invocations).not.toMatch(/cookies=$/m); // Prophylactic cookies on a clean success stay "ok" — "ok-with-cookies" // keeps meaning "cookies were needed". const outcome = await readJson( `${ROOT}/data/vidcookiegated1/download-outcome.json`, ); expect(outcome.status).toBe("ok"); expect(outcome.attempts.every((a) => a.usedCookies)).toBe(true); }); test("when-required: failed prefetch is retried with cookies -> ok-with-cookies", async ({ page, }) => { test.setTimeout(120_000); await resetData(FIXTURE); await writeSettings({ ...(await defaultSettings()), cookiesFromBrowser: COOKIES, // cookieMode omitted -> back-compat default "when-required" }); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( `Metadata prefetch auth-required (needs_auth); retrying with --cookies-from-browser ${COOKIES}`, { timeout: 60_000 }, ); await expect(log).toContainText( "Managed download complete: 2 succeeded, 0 failed", { timeout: 60_000 }, ); // Two prefetch lines for the gated video: cookie-less first, cookies second. const invocations = await readInvocations(); const gatedPrefetches = invocations .split("\n") .filter((l) => l.startsWith("prefetch:") && l.includes("vidcookiegated1")); expect(gatedPrefetches).toHaveLength(2); expect(gatedPrefetches[0]).toMatch(/cookies=$/); expect(gatedPrefetches[1]).toContain(`cookies=${COOKIES}`); // The recovered prefetch hands cookies straight to the real download. expect(invocations).toMatch( new RegExp(`youtube-single:.*vidcookiegated1 cookies=${COOKIES}`), ); // The normal video never sees cookies in when-required mode. const normalLines = invocations .split("\n") .filter((l) => l.includes("normalvideo1") && l.includes("cookies=")); expect(normalLines.length).toBeGreaterThan(0); expect(normalLines.every((l) => l.endsWith("cookies="))).toBe(true); const outcome = await readJson( `${ROOT}/data/vidcookiegated1/download-outcome.json`, ); expect(outcome.status).toBe("ok-with-cookies"); expect( outcome.attempts.some( (a) => a.kind === "metadata-prefetch-auth-retry" && a.usedCookies, ), ).toBe(true); }); test("defer: excluded from batches, surfaced in Needs cookies, downloads via the bucket button", async ({ page, }) => { test.setTimeout(180_000); await resetData(FIXTURE); await writeSettings({ ...(await defaultSettings()), cookiesFromBrowser: COOKIES, cookieMode: "defer", }); // Run 1: the gated video fails cookie-less (no prefetch retry, no auth // retry — defer never uses cookies in normal runs). await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( "Managed download complete: 1 succeeded, 1 failed", { timeout: 60_000 }, ); let invocations = await readInvocations(); expect(invocations).not.toContain(`cookies=${COOKIES}`); expect( invocations .split("\n") .filter((l) => l.startsWith("prefetch:") && l.includes("vidcookiegated1")), ).toHaveLength(1); // The failure lands the video in the snapshot's needsCookies bucket. await waitForNeedsCookiesBucket(1); // Run 2: the recorded needs_auth video is excluded, not re-attempted. await page.reload(); await page.getByRole("button", { name: "Download videos" }).click(); const log2 = page.getByLabel("Download videos output"); await expect(log2).toContainText("needs_auth deferred=1", { timeout: 60_000, }); await expect(log2).toContainText("Nothing to fetch.", { timeout: 60_000 }); // The Needs-cookies card offers the manual cookie run. const bucket = page.getByLabel("retry needs cookies bucket"); await expect(bucket).toBeVisible(); await bucket .getByRole("button", { name: /^Download with cookies \(1\)$/ }) .click(); const retryLog = page.getByLabel("Retry needs cookies output"); await expect(retryLog).toContainText( "Managed download complete: 1 succeeded, 0 failed", { timeout: 60_000 }, ); expect( await pathExists(`${ROOT}/data/vidcookiegated1/transcript.en.vtt`), ).toBe(true); invocations = await readInvocations(); expect(invocations).toMatch( new RegExp(`youtube-single:.*vidcookiegated1 cookies=${COOKIES}`), ); // The bucket empties once the snapshot regenerates. await waitForNeedsCookiesBucket(0); // …and the page refreshes onto the EMPTY bucket with the card still up and // its run's log in it (release 11 slice O3). The card used to return null on // an empty bucket, so whichever refresh landed first — the per-video // snapshot's, mid-run — unmounted the log: the intermittent "Retry needs // cookies output: element(s) not found" above. Waiting for the empty list // makes that race certain rather than likely: it renders only after the // refresh, inside the card that must have survived it. await expect(page.getByLabel("needs cookies empty")).toBeVisible({ timeout: 20_000, }); await expect(retryLog).toContainText( "Managed download complete: 1 succeeded, 0 failed", ); await expect( bucket.getByRole("button", { name: /^Download with cookies \(0\)$/ }), ).toBeDisabled(); // A fresh page has nothing to show for an empty bucket. await page.reload(); await expect(page.getByLabel("retry needs cookies bucket")).toHaveCount(0); }); test("members_only videos surface in Needs cookies regardless of mode", async ({ page, }) => { await resetData(FIXTURE); // Default settings: when-required mode. A members-only playlist video with // no downloaded artifact must still land in the bucket — cookies from a // subscribed account could recover it even though batches exclude it. await writeFile( resolvePath(`${ROOT}/playlist`), "https://www.youtube.com/watch?v=vidmembers1\n", ); const videoDir = resolvePath(`${ROOT}/data/vidmembers1`); await mkdir(videoDir, { recursive: true }); await writeFile( path.join(videoDir, "availability.json"), JSON.stringify({ checkedAt: new Date().toISOString(), availability: "members_only", webpageUrl: "https://www.youtube.com/watch?v=vidmembers1", }), ); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "download")); const bucket = page.getByLabel("retry needs cookies bucket"); await expect(bucket).toBeVisible(); await expect( bucket.getByRole("button", { name: /^Download with cookies \(1\)$/ }), ).toBeVisible(); await expect(page.getByLabel("needs cookies vidmembers1")).toBeVisible(); });