import { readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, readJson, resetData, resolvePath, writeSettings, } from "./helpers"; // Release 5 slice R. A Rumble channel's full sweep that hits HTTP 429 part-way // through the listing (the 2026-09-24 the-quartering-rumble run, in miniature): // // - every yt-dlp spawn for a rumble.com URL carries the platform args // (`--impersonate chrome --sleep-requests 1`, channelArgs.ts PLATFORM_ARGS); // - the partial listing is "incomplete", not a listing and not a failure: the // job records the rumble cooldown, logs one line, runs the paged walk // instead and SUCCEEDS, and lastFullSweepAt is never stamped; // - the next Sync is refused for the cooldown, like any other 429. // // The fake yt-dlp's `sweep429` URL sentinel prints three pages of five urls // with RumbleChannel-style "Downloading page N" lines, then the 429, exit 1 — // for a FULL enumeration only; the paged walk (-I) lists normally. const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; const channelRoot = `test-transcripts/channels/${CHANNEL}`; const RUMBLE_URL = "https://rumble.com/c/sweep429"; const ALL_IDS = [ "vidpublic1", "vidpublic2", "viddeleted1", "vidprivate1", "vidmembers1", "vidneedsauth1", ]; type ChannelConfigFile = { url?: string; lastSyncedAt?: string; lastFullSweepAt?: string; }; type AutoQueueStateFile = { download: { platformBackoff: Record; platformPace?: Record; }; }; async function readConfig(): Promise { return readJson(`${channelRoot}/config.json`); } async function readInvocations(): Promise { const raw = await readFile( resolvePath(`${channelRoot}/fake-ytdlp.invocations`), "utf8", ).catch(() => ""); return raw.split("\n").filter(Boolean); } test("a Rumble sweep that 429s mid-listing is incomplete: paged walk, cooldown, no sweep stamp", async ({ page, }) => { await resetData(FIXTURE); // The sweep is off in the e2e default settings; this spec needs it due. await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, verifyAvailabilityBeforeClean: false, syncScheduler: { enabled: false, fullSweepIntervalMinutes: 1440, fullSweepConfirmMaxSuspects: 25, fullSweepShrinkGuardPercent: 10, }, }); // Point the fixture channel at a Rumble URL carrying the sentinel. const cfgPath = resolvePath(`${channelRoot}/config.json`); const cfg = JSON.parse(await readFile(cfgPath, "utf8")) as ChannelConfigFile; await writeFile(cfgPath, JSON.stringify({ ...cfg, url: RUMBLE_URL }, null, 2)); // The paged walk's listing: everything already archived, so it stops on its // first page and downloads nothing — the download set is not the point here. await writeFile( resolvePath(`${channelRoot}/.fake-ytdlp-flat-playlist.json`), JSON.stringify({ ids: ALL_IDS }), ); await writeFile( resolvePath(`${channelRoot}/archive`), ALL_IDS.map((id) => `youtube ${id}`).join("\n") + "\n", ); const storedPlaylist = "https://rumble.com/vstale-stored.html\n"; await writeFile(resolvePath(`${channelRoot}/playlist`), storedPlaylist); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "playlist")); // Retried: a click landing before hydration fires nothing (sync-deep's idiom). // lastSyncedAt advancing is the success signal — a failed sync never stamps it. let synced = false; for (let attempt = 0; attempt < 5 && !synced; attempt++) { await page .getByRole("button", { name: "Sync", exact: true }) .click({ timeout: 5_000 }) .catch(() => {}); for (let i = 0; i < 60; i++) { const c = await readConfig().catch(() => null); if (c?.lastSyncedAt) { synced = true; break; } await new Promise((r) => setTimeout(r, 250)); } } expect(synced, "the sync job succeeded (lastSyncedAt stamped)").toBe(true); // The one "incomplete" line, naming the page, the count and the platform. const output = page.getByLabel("Sync output"); await expect(output).toContainText( "sweep incomplete: 429 at page 3 of the listing, 15 entries", { timeout: 15_000 }, ); await expect(output).toContainText("rumble cooldown"); // Platform args on every spawn: the full enumeration AND the paged walk. const inv = await readInvocations(); const full = inv.filter((l) => l.startsWith("flat-playlist:full")); const paged = inv.filter((l) => l.startsWith("flat-playlist:paged")); expect(full).toHaveLength(1); expect(paged.length).toBeGreaterThan(0); for (const line of [...full, ...paged]) { expect(line).toContain("--impersonate chrome"); expect(line).toContain(RUMBLE_URL); } // The pace adapts (release 17, slice RL): the enumeration ran at rumble's // fixed 1 s, its 429 doubled the pace, and the paged walk that followed it // paced at 2 s. for (const line of full) expect(line).toContain("--sleep-requests 1"); for (const line of paged) expect(line).toContain("--sleep-requests 2"); // The rumble cooldown is recorded where the Sync gate and the runner read it. const state = await readJson( "test-transcripts/.auto-queue/state.json", ); expect(state.download.platformBackoff.rumble?.until).toBeGreaterThan( Date.now(), ); expect(state.download.platformPace?.rumble?.sleepRequestsSeconds).toBe(2); // Not a listing: no sweep stamp, stored playlist untouched. const after = await readConfig(); expect(after.lastFullSweepAt).toBeUndefined(); expect(await readFile(resolvePath(`${channelRoot}/playlist`), "utf8")).toBe( storedPlaylist, ); // The next Sync is refused for the cooldown, as a notice. await page.goto(channelStage(CHANNEL, "playlist")); await expect(async () => { await page .getByRole("button", { name: "Sync", exact: true }) .click({ timeout: 5_000 }); await expect(page.getByLabel("Sync notice")).toContainText( "rumble is in a rate-limit cooldown", { timeout: 3_000 }, ); }).toPass({ timeout: 30_000 }); });