import { test, expect } from "@playwright/test"; import { writeFile, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; // Imported by RELATIVE path, not by package name. The other specs only ever // import types from `yt-dlp-transcript-common/...`, which are erased at compile // time; `common` publishes no `exports` map, so a runtime import of the package // specifier does not resolve under playwright's loader. The relative path does, // the same way ./helpers does — and pulling in the REAL effectiveDigest matters // here: asserting override survival against a reimplementation of the merge // would prove nothing about the merge that ships. import { effectiveDigest, type DigestOverrides, type DigestRecord, } from "../../common/lib/digest"; import { channelStage, generateReport, pathExists, readJson, resetData, resolvePath, writeChannelConfig, writeDigestVideo, writeSettings, } from "./helpers"; // The digest lane, end to end through the real job path. // // The local engine is an HTTP stub (e2e/fixtures/ollama-stub.mjs, a third // playwright webServer) rather than a fake binary, because ollama-direct POSTs // to ${ollamaUrl}/api/chat and has no binary to replace. The metered lane's // engine IS a subprocess and so uses the ordinary fake-binary idiom // (e2e/fixtures/bin/fake-claude.mjs, wired in via CLAUDE_BIN). // // Post-mutation assertions poll with reload where they read a rendered page: // the channel page serves a persisted snapshot regenerated on a ~1s debounce. // Assertions that read the SIDECARS go straight to disk and need no polling — // the batch"s closing summary line is the happens-before edge. const CHANNEL = "digest-channel"; const VIDEO = "digestvid0001"; function digestPath(channel: string, video: string): string { return join("test-transcripts", "channels", channel, "data", video, "ai-digest.json"); } function overridesPath(channel: string, video: string): string { return join( "test-transcripts", "channels", channel, "data", video, "ai-digest.overrides.json", ); } // Settings with the digest block spelled out. sanitizeDigest fills the rest. function digestSettings(over: Record = {}) { return { adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, digest: { localAppId: "ollama-direct", remoteAppId: "claude-code", sections: ["chapters"], ...over, }, }; } // The batch's closing summary line. Waiting on THAT rather than on a generic // "succeeded" is what makes these assertions a happens-before edge for the // sidecar reads below: the line is emitted after the last write. const BATCH_DONE = "Digest batch:"; async function runDigest(page: import("@playwright/test").Page, slug: string) { await generateReport(page, slug); await page.goto(channelStage(slug, "digest")); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( BATCH_DONE, { timeout: 60_000 }, ); } // --------------------------------------------------------------------------- // 1. Override survival — the single most important test in this file. // // A full sweep is weeks of wall-clock, so a hand correction that regeneration // destroys is work that can never be affordably redone. The two sidecars exist // for exactly this, and nothing else in the suite would notice if the generator // started writing through to the human file. // --------------------------------------------------------------------------- test("a human override survives a regeneration and reads back as human", async ({ page, }) => { await resetData(null); await writeSettings(digestSettings()); await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); await runDigest(page, CHANNEL); const first = await readJson(digestPath(CHANNEL, VIDEO)); const generated = first.sections.chapters?.items ?? []; expect(generated.length).toBeGreaterThan(0); expect(generated[0].decidedBy).toBe("ai"); // A human retitles the first chapter and suppresses the second, using the // ids the generator produced. const overrides: DigestOverrides = { version: 1, chapters: [ { ...generated[0], title: "Hand-written chapter title", decidedBy: "human", }, ...(generated[1] ? [{ ...generated[1], decidedBy: "human" as const, enabled: false }] : []), ], note: "corrected by hand in e2e", }; await writeFile( resolvePath(overridesPath(CHANNEL, VIDEO)), JSON.stringify(overrides, null, 2), ); const overridesBefore = await readFile( resolvePath(overridesPath(CHANNEL, VIDEO)), "utf8", ); // Force a genuine regeneration by changing the requested model — that is a // real identity change, not a test-only escape hatch, so this exercises the // same path a prompt or model change would take in production. await writeSettings( digestSettings({ apps: { "ollama-direct": { model: "qwen2.5:7b-v2" } } }), ); await runDigest(page, CHANNEL); const second = await readJson(digestPath(CHANNEL, VIDEO)); expect(second.sections.chapters?.provenance.modelRequested).toBe( "qwen2.5:7b-v2", ); // The human file is untouched, byte for byte. The generator must not be able // to reach it at all. const overridesAfter = await readFile( resolvePath(overridesPath(CHANNEL, VIDEO)), "utf8", ); expect(overridesAfter).toBe(overridesBefore); // And the composed view a reader would see honors it: the retitle wins and // reports decidedBy "human"; the suppressed item is gone without being // deleted from either file. const composed = effectiveDigest(second, overrides); const kept = composed.chapters.find( (c) => c.title === "Hand-written chapter title", ); expect(kept).toBeTruthy(); expect(kept?.decidedBy).toBe("human"); expect(composed.hasOverrides).toBe(true); if (generated[1]) { expect(composed.chapters.some((c) => c.id === generated[1].id)).toBe(false); } }); // --------------------------------------------------------------------------- // 2. Dedup sharing — the correctness crux. // // Content similarity says nothing about TIMING. A mirror with a longer intro // matches on text at shifted times, so sharing a digest onto it would place // every chapter wrong while the artifact looked perfectly healthy. // --------------------------------------------------------------------------- type ClusterFixture = { clusterId: string; members: string[]; contained?: boolean; // Pinned explicitly in these fixtures rather than left to pickCanonicalSlug. // The rule's tie-break for equal-duration, equally-transcribed members is // LEXICOGRAPHIC on the slug, which silently made the intended mirror the // canonical and inverted the assertion. Naming it here keeps each test about // the thing it is testing — the alignment gate — instead of about the // tie-break. canonicalSlug?: string; }; async function writeDuplicateReport(clusters: ClusterFixture[]) { const report = { version: 1, generatedAt: new Date().toISOString(), runConfig: { thresholdSeconds: null, durationToleranceSeconds: 2, nearThreshold: 0.6, containmentThreshold: 0.8, shingleSize: 5, }, totals: { videosScanned: clusters.reduce((a, c) => a + c.members.length, 0), clusters: clusters.length, videosInClusters: clusters.reduce((a, c) => a + c.members.length, 0), }, clusters: clusters.map((c) => ({ clusterId: c.clusterId, ...(c.canonicalSlug ? { canonicalSlug: c.canonicalSlug } : {}), matchKind: "transcript-exact", score: 1, contained: c.contained ?? false, durationBucket: 300, crossPlatform: true, crossChannel: true, videoRefs: c.members.map((slug) => { const [channelSlug, id] = slug.split("/"); return { slug, channelSlug, channel: channelSlug, platform: "youtube", id, title: `Mirror of ${id}`, duration: 600, uploadDate: "20240101", hasTranscript: true, }; }), })), }; await writeFile( resolvePath(join("test-transcripts", "duplicates.json")), JSON.stringify(report, null, 2), ); } test("a digest is shared to an aligned mirror and refused to a shifted one", async ({ page, }) => { await resetData(null); await writeSettings(digestSettings()); await writeChannelConfig(CHANNEL); // canonical + a byte-aligned mirror + a mirror whose cues are shifted 40s by // a longer intro. All three carry identical TEXT — only the timings differ, // which is precisely the case a text-similarity check cannot distinguish. await writeDigestVideo({ channelSlug: CHANNEL, videoId: "canon00000001" }); await writeDigestVideo({ channelSlug: CHANNEL, videoId: "aligned000001" }); await writeDigestVideo({ channelSlug: CHANNEL, videoId: "canon00000002" }); await writeDigestVideo({ channelSlug: CHANNEL, videoId: "shifted000001", startOffsetSeconds: 40, }); // Each cluster gets its OWN canonical member. Clusters partition the corpus, // and DigestClusterPlan.bySlug is keyed by slug — so a video listed in two // clusters would have its role silently overwritten by whichever cluster is // read last, and only one of the two outcomes would ever be observed. await writeDuplicateReport([ { clusterId: "cluster-aligned", members: [`${CHANNEL}/canon00000001`, `${CHANNEL}/aligned000001`], canonicalSlug: `${CHANNEL}/canon00000001`, }, { clusterId: "cluster-shifted", members: [`${CHANNEL}/canon00000002`, `${CHANNEL}/shifted000001`], canonicalSlug: `${CHANNEL}/canon00000002`, }, ]); await runDigest(page, CHANNEL); const aligned = await readJson( digestPath(CHANNEL, "aligned000001"), ); expect(aligned.derivedFrom?.slug).toBe(`${CHANNEL}/canon00000001`); // Sharing only happens at near-zero offset, and the record says why it was // considered safe. expect(Math.abs(aligned.derivedFrom?.offsetSeconds ?? 99)).toBeLessThanOrEqual(5); expect(aligned.sections.chapters?.items.length).toBeGreaterThan(0); // The shifted mirror was refused the share. It may still have been generated // for in its own right — what must never happen is it CARRYING the canonical // member's digest. const shifted = await pathExists(digestPath(CHANNEL, "shifted000001")); if (shifted) { const rec = await readJson(digestPath(CHANNEL, "shifted000001")); expect(rec.derivedFrom).toBeUndefined(); } }); test("a contained cluster never shares, however well it aligns", async ({ page, }) => { await resetData(null); await writeSettings(digestSettings()); await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: "canon00000001" }); await writeDigestVideo({ channelSlug: CHANNEL, videoId: "clipped000001" }); // Identical timings — this pair would sail through the alignment gate. It is // refused earlier, because containment means one member is a CLIP: the long // video's chapters describe material the clip does not contain. await writeDuplicateReport([ { clusterId: "cluster-contained", members: [`${CHANNEL}/canon00000001`, `${CHANNEL}/clipped000001`], canonicalSlug: `${CHANNEL}/canon00000001`, contained: true, }, ]); await runDigest(page, CHANNEL); const clip = await readJson(digestPath(CHANNEL, "clipped000001")); expect(clip.derivedFrom).toBeUndefined(); // It got its own digest instead of being skipped — a clip is a different // artifact and deserves one. expect(clip.sections.chapters?.items.length).toBeGreaterThan(0); }); test("a human canonical override beats the rule", async ({ page }) => { await resetData(null); await writeSettings(digestSettings()); await writeChannelConfig(CHANNEL); // Equal duration and both transcribed, so the rule falls through to the // lexicographic tie-break and would pick "aaa…". The human names the other. await writeDigestVideo({ channelSlug: CHANNEL, videoId: "aaa000000001" }); await writeDigestVideo({ channelSlug: CHANNEL, videoId: "zzz000000001" }); await writeDuplicateReport([ { clusterId: "cluster-human", members: [`${CHANNEL}/aaa000000001`, `${CHANNEL}/zzz000000001`], }, ]); await writeFile( resolvePath(join("test-transcripts", "duplicates.overrides.json")), JSON.stringify({ version: 1, clusters: { "cluster-human": { canonicalSlug: `${CHANNEL}/zzz000000001`, decidedAt: new Date().toISOString(), note: "e2e: operator picked the later id", }, }, }), ); await runDigest(page, CHANNEL); // The human's choice generated; the other received the share. const shared = await readJson(digestPath(CHANNEL, "aaa000000001")); expect(shared.derivedFrom?.slug).toBe(`${CHANNEL}/zzz000000001`); const canonical = await readJson( digestPath(CHANNEL, "zzz000000001"), ); expect(canonical.derivedFrom).toBeUndefined(); }); // --------------------------------------------------------------------------- // 3. The job path // --------------------------------------------------------------------------- test("the Digest stage queues a job and writes the sidecar", async ({ page }) => { await resetData(null); await writeSettings(digestSettings()); await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); await runDigest(page, CHANNEL); await page.goto("/jobs"); const firstRow = page.getByRole("row").nth(1); await expect(firstRow).toContainText("digest:local"); await expect(firstRow).toContainText("Digest channel (local)"); const record = await readJson(digestPath(CHANNEL, VIDEO)); expect(record.sections.chapters?.provenance.appId).toBe("ollama-direct"); expect(record.sections.chapters?.provenance.lane).toBe("local-gpu"); expect(record.digestSchemaVersion).toBe(1); // A default-configuration record carries NO promptVariant, which is what // keeps every digest written before that field existed fresh. expect(record.sections.chapters?.provenance.promptVariant).toBeUndefined(); }); test("a second run with unchanged versions is a no-op", async ({ page }) => { await resetData(null); await writeSettings(digestSettings()); await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); await runDigest(page, CHANNEL); const first = await readJson(digestPath(CHANNEL, VIDEO)); const generatedAt = first.sections.chapters?.provenance.generatedAt; await runDigest(page, CHANNEL); await expect(page.getByLabel("Digest channel output")).toContainText( "1 already current", ); // The freshness skip is what makes a re-run minutes instead of a second // multi-week sweep, so "nothing was rewritten" is the assertion. const second = await readJson(digestPath(CHANNEL, VIDEO)); expect(second.sections.chapters?.provenance.generatedAt).toBe(generatedAt); }); // --------------------------------------------------------------------------- // 4. Two lanes, two queue keys // --------------------------------------------------------------------------- test("the two lanes land on different queue keys", async ({ page }) => { await resetData(null); // longTailSeconds is lowered so the fixture video falls INSIDE the metered // lane's window. digestChannelAction routes the remote lane with // minDurationSeconds: settings.digest.longTailSeconds, because the metered // lane exists for the >4h tail — at the default the 10-minute fixture would be // out of scope and the run would legitimately find nothing to do. await writeSettings(digestSettings({ remoteEnabled: true, longTailSeconds: 60 })); await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "digest")); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( BATCH_DONE, { timeout: 60_000 }, ); // Switch to the metered lane. The control follows the lane's own default key — // if it did not, both lanes would share one key and the registry's // concurrency-1-per-key rule would serialize a GPU lane behind a network one. await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "digest")); await page.getByLabel("lane for Digest channel").selectOption("remote"); await expect(page.getByLabel("queue for Digest channel")).toHaveValue( "digest:remote", ); // The metered lane must actually have work to do, so invalidate the identity. await writeSettings( digestSettings({ remoteEnabled: true, longTailSeconds: 60, apps: { "claude-code": { model: "haiku" } }, }), ); await page.reload(); await page.getByLabel("lane for Digest channel").selectOption("remote"); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( BATCH_DONE, { timeout: 60_000 }, ); await page.goto("/jobs"); const rows = page.getByRole("row"); await expect(rows.nth(1)).toContainText("digest:remote"); await expect(rows.nth(2)).toContainText("digest:local"); // And the metered lane's own engine really ran: the record carries its lane // and the cost the CLI wrapper reported. const record = await readJson(digestPath(CHANNEL, VIDEO)); expect(record.sections.chapters?.provenance.lane).toBe("remote-api"); expect(record.sections.chapters?.provenance.appId).toBe("claude-code"); expect(record.sections.chapters?.provenance.costUsd).toBeGreaterThan(0); }); test("the metered lane is refused while it is disabled in settings", async ({ page, }) => { await resetData(null); await writeSettings(digestSettings({ remoteEnabled: false })); await writeChannelConfig(CHANNEL); await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "digest")); const lane = page.getByLabel("lane for Digest channel"); // Nothing can spend money until it is explicitly turned on, so the option is // disabled at the control rather than rejected after the click. // // Asserted via the ATTRIBUTE: playwright's toBeDisabled() reports an