// Integration: the "Missing?" presence badge, through the REAL buildIndex, over // a temp corpus — the published half of resolveMaybeMissingState. // // The export draws the badge from a summary's `state` (export e2e // availability-state.spec.ts pins state → badge with mocked summaries). What // decides that `state` for a video that fell out of its channel's listing is // buildIndex's maybe-missing overlay, and that is what this file pins: the // site's summaries page, which is where the search results and the Availability // filter read `state` (the shared transcript pages carry cues, not state). // // Release 11 slice O3: a confirm probe made after the scan that FAILED (an // `error` availability) used to publish the video as available — no badge — // because stateFromAvailability folds `error` into `available`. It now stays // maybe_missing, like a video never probed. // // Run with: node_modules/.bin/tsx --test common/controller/maybeMissingBuild.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; // getPaths() is lazy and cached, and nothing above calls it at import time, so // pointing the whole path graph at a temp root here isolates this file's // process from the real corpus (the curatedTagsBuild.test.ts pattern). const ROOT = mkdtempSync(path.join(tmpdir(), "maybe-missing-build-")); process.env.TRANSCRIPTS_DIR = path.join(ROOT, "transcripts"); process.env.EXPORT_PUBLIC_DIR = path.join(ROOT, "public"); process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); const { getPaths } = await import("../lib/paths"); const { buildIndex } = await import("./buildIndex"); const paths = getPaths(); const CHANNEL = "test-channel"; const SITE = "testsite"; const SCAN_AT = "2026-08-01T12:00:00.000Z"; const BEFORE_SCAN = "2026-07-20T00:00:00.000Z"; const AFTER_SCAN = "2026-08-02T00:00:00.000Z"; // One video per outcome of the overlay. Only LISTED is in the channel's // listing; every other id is in maybe-missing.json. const LISTED = "vid-listed"; const NEVER_PROBED = "vid-never-probed"; const ERROR_AFTER = "vid-error-after"; const PUBLIC_AFTER = "vid-public-after"; const PUBLIC_BEFORE = "vid-public-before"; const DELETED = "vid-deleted"; function writeJson(file: string, value: unknown): void { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(value, null, 2)); } function videoDir(id: string): string { return path.join(paths.channelsDir, CHANNEL, "data", id); } function seedCorpus(): void { writeFileSync(paths.settingsFile, JSON.stringify({})); writeJson(path.join(paths.channelsDir, CHANNEL, "config.json"), { handling: "youtube", name: "Test Channel", url: "https://www.youtube.com/@example/videos", }); const ids = [LISTED, NEVER_PROBED, ERROR_AFTER, PUBLIC_AFTER, PUBLIC_BEFORE, DELETED]; ids.forEach((id, i) => { writeJson(path.join(videoDir(id), "metadata.info.json"), { id, title: `Video ${id}`, channel: "Test Channel", upload_date: `2026010${i + 1}`, duration: 120, description: "fixture", webpage_url: `https://www.youtube.com/watch?v=${id}`, extractor_key: "Youtube", }); writeFileSync( path.join(videoDir(id), "transcript.en.vtt"), "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nA line of transcript.\n", ); }); const availability = (id: string, availability: string, checkedAt: string) => writeJson(path.join(videoDir(id), "availability.json"), { checkedAt, availability }); availability(ERROR_AFTER, "error", AFTER_SCAN); availability(PUBLIC_AFTER, "public", AFTER_SCAN); availability(PUBLIC_BEFORE, "public", BEFORE_SCAN); availability(DELETED, "deleted", BEFORE_SCAN); writeJson(path.join(paths.channelsDir, CHANNEL, "maybe-missing.json"), { checkedAt: SCAN_AT, freshPlaylistCount: 1, ids: [NEVER_PROBED, ERROR_AFTER, PUBLIC_AFTER, PUBLIC_BEFORE, DELETED], }); writeJson(path.join(paths.sitesDir, SITE, "site.json"), { siteId: SITE, siteTitle: "Test Site", siteDescription: "fixture", headerTitle: "Test Site", homeTagline: "", socialLinks: [], groups: [{ id: "default", name: "All channels", selectedByDefault: true }], defaultGroupId: "default", channels: [{ slug: CHANNEL, groupId: "default" }], }); } type Published = { id: string; state?: string; isDeleted?: boolean }; function readPage(file: string): Published[] { return JSON.parse(readFileSync(file, "utf8")) as Published[]; } const summariesPage = () => readPage(path.join(paths.exportSitesIndexDir, SITE, "summaries", "page-0000.json")); function stateOf(page: Published[], id: string): string { const record = page.find((r) => r.id === id); assert.ok(record, `${id} is published`); // The build omits `state` for an available video (the badge's absence). return record.state ?? "available"; } test("the published state of each video that left the listing, as the badge reads it", async () => { seedCorpus(); const log: string[] = []; const res = await buildIndex({ paths, onLog: (s) => log.push(s) }); assert.equal(res.totalCount, 6); const expected: Record = { [LISTED]: "available", [NEVER_PROBED]: "maybe_missing", // THE CASE: the confirm probe failed after the scan. No evidence either // way, so the badge stays. [ERROR_AFTER]: "maybe_missing", [PUBLIC_AFTER]: "available", [PUBLIC_BEFORE]: "maybe_missing", [DELETED]: "deleted", }; const page = summariesPage(); const got = Object.fromEntries(Object.keys(expected).map((id) => [id, stateOf(page, id)])); assert.deepEqual(got, expected); // The build's own count line names the three unconfirmed videos. assert.ok( log.some((l) => l.includes("Availability: 3 video(s) unconfirmed-missing from a channel listing.")), log.filter((l) => l.startsWith("Availability")).join("\n") || "no Availability line", ); });