import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, readJson, resetData, resolvePath, } from "./helpers"; const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; // The baseline fixture's data dirs (canonical ids). vidunlisted1 is created at // test time below so we can exercise the unlisted-resolves-via-dump-json path. const BASELINE_IDS = [ "vidpublic1", "vidpublic2", "viddeleted1", "vidprivate1", "vidmembers1", "vidneedsauth1", ]; const channelRoot = `test-transcripts/channels/${CHANNEL}`; function dataDir(id: string): string { return `${channelRoot}/data/${id}`; } function availabilityPath(id: string): string { return `${dataDir(id)}/availability.json`; } type AvailabilityFile = { checkedAt: string; availability: string; webpageUrl?: string; history?: Array<{ availability: string; observedAt: string; source: string; }>; }; async function readAvailability(id: string): Promise { return readJson(availabilityPath(id)); } // Seed an extra video dir with metadata so it counts as a known on-disk video. async function seedVideoDir(id: string): Promise { const dir = resolvePath(dataDir(id)); await mkdir(dir, { recursive: true }); await writeFile( resolvePath(`${dataDir(id)}/metadata.info.json`), JSON.stringify( { id, title: `Synthetic ${id}`, webpage_url: `https://www.youtube.com/watch?v=${id}`, extractor_key: "Youtube", }, null, 2, ), ); } // Write the sidecar that controls which ids the fake yt-dlp's flat-playlist // branch emits. It lives in the channel cwd (where availability spawns run). async function setFreshPlaylist(ids: string[]): Promise { await writeFile( resolvePath(`${channelRoot}/.fake-ytdlp-flat-playlist.json`), JSON.stringify({ ids }), ); } // The quick-check job arms a debounced (~1s) snapshot regen. Wait until // snapshot.json re-surfaces a non-empty maybeMissing set so the reloaded page // renders the list + "Full-check unexpected" button. async function waitForSnapshotMaybeMissing(): Promise { await expect .poll( async () => { const snap = await readJson<{ maybeMissing?: { ids: string[] } }>( `${channelRoot}/snapshot.json`, ).catch(() => null); return snap?.maybeMissing?.ids?.length ?? 0; }, { timeout: 15_000 }, ) .toBeGreaterThan(0); } async function readInvocations(): Promise { try { return await readFile( resolvePath(`${channelRoot}/fake-ytdlp.invocations`), "utf8", ); } catch { return ""; } } test("quick check flags videos missing from the fresh playlist", async ({ page, }) => { await resetData(FIXTURE); await seedVideoDir("vidunlisted1"); // Fresh playlist omits viddeleted1 and vidunlisted1. await setFreshPlaylist(BASELINE_IDS.filter((id) => id !== "viddeleted1")); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "diagnostics")); await page .getByRole("button", { name: "Quick check (flat playlist)" }) .click(); const log = page.getByLabel("Quick availability check output"); await expect(log).toContainText("maybe-missing", { timeout: 20_000 }); const record = await readJson<{ ids: string[]; freshPlaylistCount: number }>( `${channelRoot}/maybe-missing.json`, ); expect(record.ids.sort()).toEqual(["viddeleted1", "vidunlisted1"].sort()); // The job arms a debounced (~1s) snapshot regen that re-surfaces the // maybe-missing set into snapshot.json. Wait for it before reloading. await waitForSnapshotMaybeMissing(); // Reload — the maybe-missing list persists (re-surfaced via the snapshot). await page.reload(); const list = page.getByLabel("maybe missing list"); await expect(list).toContainText("viddeleted1"); await expect(list).toContainText("vidunlisted1"); }); test("full-check skips hard-excluded but re-checks unlisted/needs_auth", async ({ page, }) => { await resetData(FIXTURE); await seedVideoDir("vidunlisted1"); // vidmembers1 already known members_only → must be skipped by the full check. await writeFile( resolvePath(availabilityPath("vidmembers1")), JSON.stringify({ checkedAt: new Date().toISOString(), availability: "members_only", }), ); // Fresh playlist omits the three we want flagged. await setFreshPlaylist(["vidpublic1", "vidpublic2", "vidprivate1"]); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "diagnostics")); await page .getByRole("button", { name: "Quick check (flat playlist)" }) .click(); await expect( page.getByLabel("Quick availability check output"), ).toContainText("maybe-missing", { timeout: 20_000 }); await waitForSnapshotMaybeMissing(); await page.reload(); await page.getByRole("button", { name: "Full-check unexpected" }).click(); await expect( page.getByLabel("Full-check maybe-missing videos output"), ).toContainText("Availability check done", { timeout: 20_000 }); const invocations = await readInvocations(); // The hard-excluded members_only video is skipped: no dump-json probe. expect(invocations).not.toContain("dump-json:https://www.youtube.com/watch?v=vidmembers1"); // viddeleted1, vidunlisted1, vidneedsauth1 are re-checked. expect(invocations).toContain("dump-json:https://www.youtube.com/watch?v=vidunlisted1"); expect(invocations).toContain("dump-json:https://www.youtube.com/watch?v=viddeleted1"); expect(invocations).toContain("dump-json:https://www.youtube.com/watch?v=vidneedsauth1"); }); test("full-check resolves deleted vs unlisted and records history", async ({ page, }) => { await resetData(FIXTURE); await seedVideoDir("vidunlisted1"); await setFreshPlaylist(["vidpublic1", "vidpublic2"]); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "diagnostics")); await page .getByRole("button", { name: "Quick check (flat playlist)" }) .click(); await expect( page.getByLabel("Quick availability check output"), ).toContainText("maybe-missing", { timeout: 20_000 }); await waitForSnapshotMaybeMissing(); await page.reload(); await page.getByRole("button", { name: "Full-check unexpected" }).click(); await expect( page.getByLabel("Full-check maybe-missing videos output"), ).toContainText("Availability check done", { timeout: 20_000 }); const deleted = await readAvailability("viddeleted1"); const unlisted = await readAvailability("vidunlisted1"); expect(deleted.availability).toBe("deleted"); expect(unlisted.availability).toBe("unlisted"); // History append-on-change: each got exactly one entry recorded. const delHist = deleted.history ?? []; expect(delHist.length).toBe(1); expect(delHist[0]).toMatchObject({ availability: "deleted", source: "check", }); // Re-run the full check with no change → no duplicate history entry. await page.getByRole("button", { name: "Full-check unexpected" }).click(); // Wait for a second "done" line. await expect( page.getByLabel("Full-check maybe-missing videos output"), ).toContainText("Availability check done", { timeout: 20_000 }); await expect .poll(async () => (await readAvailability("viddeleted1")).history?.length, { timeout: 10_000, }) .toBe(1); // vidunlisted1 also got exactly one history entry recorded. const unlHist = unlisted.history ?? []; expect(unlHist.length).toBe(1); expect(unlHist[0]).toMatchObject({ availability: "unlisted", source: "check", }); });