// Social (posts) channels end to end: create one through the real form, run a // fetch-posts job, and assert the on-disk effect. // // X is NEVER contacted. The fetch runs against e2e/fixtures/bin/fake-gallery-dl.mjs // (wired in via GALLERY_DL_BIN by `pnpm dev:test`), which prints the same // `--dump-json` records the real binary does — so the suite stays deterministic // and no test run can risk a real account. import { test, expect } from "@playwright/test"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resetData, readJson, pathExists, resolvePath, generateReport } from "./helpers"; import { THREAD_URL as FORUM_THREAD_URL, threadPage, } from "yt-dlp-transcript-common/social/__fixtures__/xenforoPages"; type ChannelConfig = { handling: string; sourceKind?: string; platform?: string; postFetcher?: string; socialHandle?: string; url?: string; name?: string; }; const X_URL = "https://x.com/faketester"; const BSKY_URL = "https://bsky.app/profile/someone.bsky.social"; async function readJsonl(relPath: string): Promise[]> { const raw = await readFile(resolvePath(relPath), "utf8"); return raw .split("\n") .filter((l) => l.trim()) .map((l) => JSON.parse(l) as Record); } test("an X URL derives a social source offline and hides the video-only controls", async ({ page, }) => { await resetData("empty"); await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(X_URL); // Offline autofill: detectPlatform() alone classifies the URL, no network. await expect(page.locator('select[name="platform"]')).toHaveValue("twitter"); // A social account has no audio to transcribe, so the video-only sections // must not render at all (rather than showing inert controls). await expect(page.locator('input[name="socialHandle"]')).toHaveValue("faketester"); await expect(page.locator('select[name="audioFormat"]')).toHaveCount(0); await expect(page.locator('select[name="downloadFormat"]')).toHaveCount(0); await expect(page.locator('input[name="keepSourceVideo"]')).toHaveCount(0); await expect(page.locator('input[name="savedVideosDir"]')).toHaveCount(0); // Handling is a video axis — replaced by a fixed hidden value. await expect(page.locator('input[name="handling"][type="radio"]')).toHaveCount(0); }); test("a Bluesky URL is also recognised as a social source", async ({ page }) => { await resetData("empty"); await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(BSKY_URL); await expect(page.locator('select[name="platform"]')).toHaveValue("bluesky"); await expect(page.locator('input[name="socialHandle"]')).toHaveValue( "someone.bsky.social", ); }); test("a video URL still shows the video controls (no social regression)", async ({ page, }) => { await resetData("empty"); await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill("https://www.youtube.com/@Veritasium/videos"); await expect(page.locator('select[name="platform"]')).toHaveValue("youtube"); await expect(page.locator('select[name="audioFormat"]')).toHaveCount(1); await expect(page.locator('input[name="handling"][type="radio"]')).toHaveCount(2); await expect(page.locator('input[name="socialHandle"]')).toHaveCount(0); }); test("creating a social channel round-trips through parseChannelConfig", async ({ page, }) => { await resetData("empty"); await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(X_URL); await page.locator('input[name="name"]').fill("Fake Tester"); await page.locator('input[name="slug"]').fill("faketester"); // Don't fetch on create — this test is about config persistence only. await page.locator('input[name="fetchPostsNow"]').uncheck(); await page.getByRole("button", { name: /create|add channel|save/i }).first().click(); await expect .poll( async () => (await pathExists("test-transcripts/channels/faketester/config.json")) ? await readJson( "test-transcripts/channels/faketester/config.json", ) : null, { timeout: 15_000 }, ) .toMatchObject({ sourceKind: "social", platform: "twitter", postFetcher: "x-gallery-dl", socialHandle: "faketester", url: X_URL, }); }); test("fetch-posts writes month-sharded JSONL + a posts-archive, and re-running is incremental", async ({ page, }) => { await resetData("empty"); await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(X_URL); await page.locator('input[name="name"]').fill("Fake Tester"); await page.locator('input[name="slug"]').fill("faketester"); // "Fetch posts now" is on by default — this is the create-time fetch path. await page.getByRole("button", { name: /create|add channel|save/i }).first().click(); // The fake binary emits three tweets across two months. await expect .poll( async () => (await pathExists("test-transcripts/channels/faketester/posts-archive")) ? ( await readFile( resolvePath("test-transcripts/channels/faketester/posts-archive"), "utf8", ) ) .split("\n") .filter(Boolean).length : 0, { timeout: 30_000 }, ) .toBe(3); const june = await readJsonl( "test-transcripts/channels/faketester/posts/2026-06.jsonl", ); const may = await readJsonl( "test-transcripts/channels/faketester/posts/2026-05.jsonl", ); expect(june).toHaveLength(2); expect(may).toHaveLength(1); // Ids stay strings: a 64-bit tweet id put through a JSON number would be // silently corrupted. expect(june[0].id).toBe("1899000000000000001"); expect(june[0].platform).toBe("twitter"); expect(june[0].uploadDate).toBe("20260601"); expect(june[0].slug).toBe("faketester/1899000000000000001"); expect(june[0].links).toEqual(["https://example.com/fake"]); // The reply carries the thread structure. expect(june[1].isReply).toBe(true); expect(june[1].threadId).toBe("1899000000000000001"); // Re-run: the fake binary is deterministic, so every record comes back — and // the archive must dedupe all of them rather than doubling the corpus. await generateReport(page, "faketester"); await page.goto("/channels/faketester"); await page.getByRole("button", { name: /fetch posts/i }).first().click(); await expect .poll( async () => { const june2 = await readJsonl( "test-transcripts/channels/faketester/posts/2026-06.jsonl", ); const archive = ( await readFile( resolvePath("test-transcripts/channels/faketester/posts-archive"), "utf8", ) ) .split("\n") .filter(Boolean); return { june: june2.length, archive: archive.length }; }, { timeout: 30_000 }, ) .toEqual({ june: 2, archive: 3 }); }); // A FORUM THREAD (XenForo): the URL alone makes a forum-thread channel. The // forum is invented (forum.example) and never contacted: the create skips its // first fetch, and the posts come in through Import saved pages, from a // SYNTHETIC page written to a temp dir. test("a forum thread URL makes a forum-thread channel, and saved pages import into it", async ({ page, }) => { await resetData("empty"); await generateReport(page, "new"); await page.goto("/channels/new"); await page.locator('input[name="url"]').fill(`${FORUM_THREAD_URL}page-3`); await expect(page.locator('select[name="platform"]')).toHaveValue("xenforo"); await expect(page.locator('input[name="socialHandle"]')).toHaveValue( "the-teapot-collectors-thread.4242", ); await expect(page.locator('input[name="slug"]')).toHaveValue("the-teapot-collectors-thread"); await expect(page.locator('select[name="audioFormat"]')).toHaveCount(0); await page.locator('input[name="name"]').fill("Teapot thread"); await page.locator('input[name="fetchPostsNow"]').uncheck(); await page.getByRole("button", { name: /create|add channel|save/i }).first().click(); const configPath = "test-transcripts/channels/the-teapot-collectors-thread/config.json"; await expect .poll(async () => ((await pathExists(configPath)) ? await readJson(configPath) : null), { timeout: 15_000, }) .toMatchObject({ sourceKind: "social", platform: "xenforo", postFetcher: "xenforo-thread", socialHandle: "the-teapot-collectors-thread.4242", }); const saves = await mkdtemp(path.join(os.tmpdir(), "forum-saves-")); await mkdir(path.join(saves, "The Teapot Collectors Thread_files"), { recursive: true }); const post = (id: number, position: number) => ({ id, author: `Member${id % 3}`, userId: 10 + (id % 3), ts: 1_760_000_000 + position * 60, position, body: `Words of post ${position}.`, }); await writeFile( path.join(saves, "The Teapot Collectors Thread _ Page 2.html"), threadPage({ page: 2, last: 2, saved: true, posts: [post(21, 21), post(22, 22), post(23, 23)] }), ); await generateReport(page, "the-teapot-collectors-thread"); await page.goto("/channels/the-teapot-collectors-thread"); await expect(page.locator("[data-forum-session]")).toBeVisible(); await expect(page.getByLabel("pages to fetch")).toBeVisible(); await page.getByLabel("saved pages path").fill(saves); await page.getByRole("button", { name: "import saved pages" }).click(); const archive = "test-transcripts/channels/the-teapot-collectors-thread/posts-archive"; await expect .poll( async () => (await pathExists(archive)) ? (await readFile(resolvePath(archive), "utf8")).split("\n").filter(Boolean) : [], { timeout: 30_000 }, ) .toEqual(["xenforo 21", "xenforo 22", "xenforo 23"]); });