Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a5b31f1df255a4e5cb122d3d6c61b35838066720
parent 85d56ad907bdaed9c18234c84115b63184dfd593
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 11:45:14 -0400

tests: move pure and route-handler e2e to unit tests; root pnpm test and pnpm typecheck

The editor's e2e suite booted a dev server to test a pure ffmpeg classifier,
a dispatcher's 404s and the unit-executor protocol's route handlers. They are
unit tests now, and the suite shrinks by 19 (733 -> 714 by playwright --list):

- audio-check-classifier.spec (6) -> common/ytdlp/ffmpegStreamClassify.test.ts.
- view-route.spec (12 -> 1) -> app/api/view/[name]/route.test.ts: the rewrite
  table in next.config.ts read as data (each old path a rewrite to its view,
  and every view one), the dispatcher's 404s for unknown and every
  /api/test/* harness name, and /api/widget/presets. What stays e2e is the
  one thing only a running Next can show: a rewrite carries the query string.
- worker-unit.spec (2) -> app/api/worker/unit/route.test.ts, the four
  handlers in-process against a temp corpus and an ollama stub.
- media-file-abort.spec stays e2e: a burst of cancelled bodies against the
  handler in-process passes even with the naive wrapper that caused the bug,
  so only the HTTP round trip tests it. The handler's ranges get a unit test
  beside it, and the guard itself one in common/lib/safeStreamController.

Root `pnpm test` runs every package's unit suite and test:scripts;
`pnpm typecheck` is the tsc sweep. Both pass --no-sort: under pnpm 11,
--no-bail alone still skips every DEPENDENT of a failed package, so a red
common silently ran no editor, export, homepage or mcp test at all.

The editor's `lint` script is dropped: it had no eslint config, and
export's config finds 35 errors in 25 files there (17 of them
react-hooks/set-state-in-effect), each a behaviour change in a UI file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/lib/safeStreamController.test.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/ytdlp/ffmpegStreamClassify.test.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/channels/[slug]/videos/[id]/files/[name]/route.test.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/view/[name]/route.test.ts | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/worker/unit/route.test.ts | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/e2e/audio-check-classifier.spec.ts | 70----------------------------------------------------------------------
Meditor/e2e/view-route.spec.ts | 89+++++--------------------------------------------------------------------------
Deditor/e2e/worker-unit.spec.ts | 170-------------------------------------------------------------------------------
Meditor/package.json | 1-
Mpackage.json | 2++
10 files changed, 532 insertions(+), 325 deletions(-)

diff --git a/common/lib/safeStreamController.test.ts b/common/lib/safeStreamController.test.ts @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { makeSafeController } from "./safeStreamController"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test lib/safeStreamController.test.ts +// +// The guard the media file route and the job log streams wrap their +// controllers in. A consumer that goes away (a scrubbing <audio> cancelling its +// range request) closes the stream under the producer; a raw controller then +// throws ERR_INVALID_STATE from inside the encoder's pipeline, where it leaks as +// an uncaughtException. The safe one goes quiet instead. + +function stream() { + const safe = makeSafeController<Uint8Array>(); + let raw!: ReadableStreamDefaultController<Uint8Array>; + const rs = new ReadableStream<Uint8Array>({ + start(c) { + raw = c; + safe.setController(c); + }, + }); + return { safe, raw: () => raw, rs }; +} + +test("a raw controller throws once the stream is closed — the hazard", () => { + const { raw } = stream(); + raw().close(); + assert.throws(() => raw().enqueue(new Uint8Array(1)), { + code: "ERR_INVALID_STATE", + }); +}); + +test("after a cancel, enqueue/close/error are no-ops", async () => { + const { safe, rs } = stream(); + await rs.cancel(); + safe.markClosed(); + safe.safeEnqueue(new Uint8Array(1)); + safe.safeClose(); + safe.safeError(new Error("late")); + assert.equal(safe.isClosed(), true); +}); + +test("a stream closed under it is noticed at the next enqueue, without a throw", () => { + const { safe, raw } = stream(); + // Closed by someone else — the guard has not been told. + raw().close(); + assert.equal(safe.isClosed(), false); + safe.safeEnqueue(new Uint8Array(1)); + assert.equal(safe.isClosed(), true); + safe.safeClose(); + safe.safeError(new Error("late")); +}); + +test("close and error each happen once", async () => { + const { safe, rs } = stream(); + const reader = rs.getReader(); + safe.safeEnqueue(new Uint8Array([1, 2])); + safe.safeClose(); + safe.safeClose(); + safe.safeError(new Error("after close")); + assert.deepEqual((await reader.read()).value, new Uint8Array([1, 2])); + assert.equal((await reader.read()).done, true); +}); diff --git a/common/ytdlp/ffmpegStreamClassify.test.ts b/common/ytdlp/ffmpegStreamClassify.test.ts @@ -0,0 +1,70 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { classifyFfmpegProbe } from "./ffmpegStreamClassify"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test ytdlp/ffmpegStreamClassify.test.ts +// +// The ffmpeg probe-result classifier. Pure: an exit code and a stderr string +// in, a verdict out. (These lived in the editor's e2e suite as +// audio-check-classifier.spec.ts, booting a dev server they never used.) + +const DECODER_ERROR = + "[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n"; +const PARTIAL_FILE = + "[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x4] stream 1, offset 0x1626f8a: partial file\n"; + +test("exit 0 with empty stderr → clean", () => { + assert.equal(classifyFfmpegProbe(0, ""), "clean"); + assert.equal(classifyFfmpegProbe(0, "\n \t\n"), "clean"); +}); + +test("exit 0 with 'partial file' stderr → partial", () => { + assert.equal( + classifyFfmpegProbe( + 0, + "[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x1234] stream 1, offset 0x10483924: partial file\n", + ), + "partial", + ); +}); + +test("exit 0 with many decoder errors and no 'partial file' → malformed", () => { + // ffmpeg can exit 0 even when the av_codec layer rejects hundreds of + // packets — the encoder keeps producing output from whatever decoded. A wall + // of "Error submitting packet to decoder" lines without a "partial file" + // demuxer warning is mid-stream corruption, not a clean truncation. + const aacStorm = Array.from( + { length: 50 }, + (_, i) => + `[aac @ 0x1] channel element ${i % 3}.${i % 16} is not allocated\n` + + DECODER_ERROR, + ).join(""); + assert.equal(classifyFfmpegProbe(0, aacStorm), "malformed"); +}); + +test("exit 0 with many decoder errors AND 'partial file' → malformed (corruption wins over truncation)", () => { + const stormPlusPartial = + Array.from({ length: 50 }, () => DECODER_ERROR).join("") + PARTIAL_FILE; + assert.equal(classifyFfmpegProbe(0, stormPlusPartial), "malformed"); +}); + +test("exit 0 with a small tail of decoder errors AND 'partial file' → partial", () => { + // Truncated containers often emit a couple of trailing decoder errors as the + // encoder eats the last partial packets. Below the threshold the file is + // still classifiable as partial. + const tail = DECODER_ERROR + DECODER_ERROR + PARTIAL_FILE; + assert.equal(classifyFfmpegProbe(0, tail), "partial"); +}); + +test("non-zero exit → malformed (regardless of stderr)", () => { + assert.equal( + classifyFfmpegProbe( + 1, + "[aac @ 0x1] Sample rate index in program config element does not match the sample rate index configured by the container.\n", + ), + "malformed", + ); + assert.equal(classifyFfmpegProbe(2, ""), "malformed"); + assert.equal(classifyFfmpegProbe(null, "killed by signal"), "malformed"); +}); diff --git a/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.test.ts b/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// Run with: +// pnpm -C editor exec tsx --test "app/api/channels/*/videos/*/files/*/route.test.ts" +// +// The media file route's byte ranges, in-process. The ABORT regression +// (`Controller is already closed` when a scrubbing browser cancels range +// requests) stays e2e in media-file-abort.spec.ts: a burst of cancelled +// bodies run against this handler in-process passes even with the naive +// wrapper that caused it — the race is in the server's response pipeline, not +// in the handler — so only the HTTP round trip is a real test of it. The guard +// itself is unit-tested in common/lib/safeStreamController.test.ts. + +const ROOT = await mkdtemp(path.join(os.tmpdir(), "media-file-route-")); +const CORPUS = path.join(ROOT, "transcripts"); +const VIDEO_DIR = path.join(CORPUS, "channels", "chan", "data", "vidA"); +await mkdir(VIDEO_DIR, { recursive: true }); +await writeFile(path.join(VIDEO_DIR, "audio.m4a"), Buffer.alloc(1024 * 1024, 7)); +// Set before anything that caches getPaths() is first imported. +process.env.TRANSCRIPTS_DIR = CORPUS; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); +const { GET } = await import("./route"); +test.after(() => rm(ROOT, { recursive: true, force: true })); + +function get(range?: string) { + return GET( + new Request("http://localhost/api/channels/chan/videos/vidA/files/audio.m4a", { + headers: range ? { range } : {}, + }), + { params: Promise.resolve({ slug: "chan", id: "vidA", name: "audio.m4a" }) }, + ); +} + +test("a range request is a 206 with the bytes asked for", async () => { + const res = await get("bytes=100-4195"); + assert.equal(res.status, 206); + assert.equal(res.headers.get("content-range"), "bytes 100-4195/1048576"); + const body = Buffer.from(await res.arrayBuffer()); + assert.equal(body.length, 4096); +}); + +test("a suffix range is the last N bytes; no or a bad range is the whole file", async () => { + const suffix = await get("bytes=-100"); + assert.equal(suffix.status, 206); + assert.equal(suffix.headers.get("content-range"), "bytes 1048476-1048575/1048576"); + assert.equal((await suffix.arrayBuffer()).byteLength, 100); + for (const range of [undefined, "bytes=5-2", "bytes=0-9999999", "lines=1-2"]) { + const res = await get(range); + assert.equal(res.status, 200, String(range)); + assert.equal(res.headers.get("content-length"), "1048576"); + await res.body!.cancel(); + } +}); + +test("a path that leaves the video dir is refused; a missing file is a 404", async () => { + const bad = await GET(new Request("http://localhost/x"), { + params: Promise.resolve({ slug: "chan", id: "..%2F..", name: "audio.m4a" }), + }); + assert.equal(bad.status, 400); + const missing = await GET(new Request("http://localhost/x"), { + params: Promise.resolve({ slug: "chan", id: "vidA", name: "nope.m4a" }), + }); + assert.equal(missing.status, 404); +}); diff --git a/editor/app/api/view/[name]/route.test.ts b/editor/app/api/view/[name]/route.test.ts @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { VIEW_NAMES } from "yt-dlp-transcript-common/views/names"; + +// Run with: +// pnpm -C editor exec tsx --test "app/api/view/[name]/route.test.ts" +// +// ONE POLLING ROUTE, AND THE OLD PATHS THAT STILL ANSWER — the parts of that +// contract a handler call can check. They were e2e (view-route.spec.ts) and +// booted a server to ask a dispatcher for a 404. +// +// - the eight old paths are REWRITES to their view, and nothing else is: the +// table in next.config.ts, read as data. A rewrite to the right view is +// the same endpoint by construction, which is what the e2e compared bodies +// to prove. +// - an unknown name, a near-miss and every /api/test/* harness name are 404s +// from the dispatcher, before any input is built. +// - /api/widget/presets keeps its own route and answers. +// +// What stays e2e is the one thing only a running Next can show: that a +// rewrite carries the QUERY STRING (`/api/pulse?rev=` — e2e/view-route.spec.ts). + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EDITOR = path.resolve(HERE, "..", "..", "..", ".."); + +const ROOT = await mkdtemp(path.join(os.tmpdir(), "view-route-")); +const CORPUS = path.join(ROOT, "transcripts"); +await mkdir(path.join(CORPUS, "channels"), { recursive: true }); +// Set before anything that caches getPaths() is first imported. +process.env.TRANSCRIPTS_DIR = CORPUS; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); +const { GET } = await import("./route"); +const { GET: presetsGET } = await import("../../widget/presets/route"); +// next.config.ts says `__dirname`, which Next's config loader provides and an +// ES module does not; a global of that name is what the free identifier finds. +(globalThis as { __dirname?: string }).__dirname = EDITOR; +const { default: nextConfig } = await import("../../../../next.config"); +delete (globalThis as { __dirname?: string }).__dirname; +test.after(() => rm(ROOT, { recursive: true, force: true })); + +const PAIRS: Array<[string, string]> = [ + ["/api/pulse", "/api/view/pulse"], + ["/api/jobs/active", "/api/view/activeJobs"], + ["/api/workers", "/api/view/workers"], + ["/api/auto-queue/status", "/api/view/autoQueueStatus"], + ["/api/scheduler/status", "/api/view/schedulerStatus"], + ["/api/widget/sync", "/api/view/widgetSync"], + ["/api/widget/actionable", "/api/view/widgetActionable"], + ["/api/widget/cleanable", "/api/view/cleanable"], +]; + +async function view(name: string): Promise<number> { + const res = await GET(new Request(`http://localhost/api/view/${name}`), { + params: Promise.resolve({ name }), + }); + return res.status; +} + +type Rewrite = { source: string; destination: string }; + +async function rewrites(): Promise<Rewrite[]> { + const r = await nextConfig.rewrites!(); + // The array form is `afterFiles`; the object form would split it. + assert.ok(Array.isArray(r), "next.config rewrites() is the array form"); + return r as Rewrite[]; +} + +test("each old path is a rewrite to its view, and every view has one", async () => { + const table = await rewrites(); + const apiRewrites = table.filter((r) => r.source.startsWith("/api/")); + assert.deepEqual( + apiRewrites.map((r) => [r.source, r.destination]), + PAIRS, + ); + // Every destination is a name the dispatcher serves — a typo here would be a + // rewrite to a 404. + for (const [, viewPath] of PAIRS) { + const name = viewPath.slice("/api/view/".length); + assert.ok( + (VIEW_NAMES as readonly string[]).includes(name), + `${viewPath} is not a view`, + ); + } + assert.deepEqual( + [...VIEW_NAMES].sort(), + PAIRS.map(([, v]) => v.slice("/api/view/".length)).sort(), + ); +}); + +test("an unknown view name is 404, not 500", async () => { + for (const name of ["nope", "Pulse", "activejobs", "presets"]) { + assert.equal(await view(name), 404, `/api/view/${name}`); + } +}); + +// The dispatcher has no guard by design (these are read-only polls), but the +// harness routes DO — and none may be reachable through it. +test("no test-harness name is a view", async () => { + const harness = ( + await readdir(path.join(EDITOR, "app", "api", "test"), { + withFileTypes: true, + }) + ) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + assert.ok(harness.includes("invalidate-cache"), "the harness dir was read"); + for (const name of harness) { + assert.equal(await view(name), 404, `/api/view/${name}`); + } +}); + +// /api/widget/presets is a menu fetch on open, not a poll: it is not a view, +// it keeps its own route, and no rewrite shadows it. +test("/api/widget/presets is untouched", async () => { + const table = await rewrites(); + assert.ok(!table.some((r) => r.source === "/api/widget/presets")); + const res = await presetsGET(); + assert.equal(res.status, 200); + const body = (await res.json()) as { builtIn: unknown; saved: unknown }; + assert.ok(Array.isArray(body.builtIn)); + assert.ok(Array.isArray(body.saved)); +}); diff --git a/editor/app/api/worker/unit/route.test.ts b/editor/app/api/worker/unit/route.test.ts @@ -0,0 +1,197 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// Run with: +// pnpm -C editor exec tsx --test "app/api/worker/unit/route.test.ts" +// +// The unit-executor protocol (/api/worker/unit) — the generalisation of the +// remote-transcription protocol to backfill kinds — driven through its four +// route handlers in-process, in a temp corpus. (It was e2e, worker-unit.spec.ts, +// and needed nothing of the server but these handlers.) Three angles: +// 1. Auth + the door guard (only backfill KINDS are accepted — download and +// transcription are refused, which is what keeps download politeness +// single-machine). +// 2. A full round trip: an attribution-text unit whose model calls land on an +// ollama STUB started here — proving the scratch-corpus materialization +// (cues written last passes the mtime freshness gate), the config +// injection, and the result pull, with no real model anywhere. +// 3. Cleanup: DELETE removes the scratch and the result 404s. + +const ROOT = await mkdtemp(path.join(os.tmpdir(), "worker-unit-route-")); +const CORPUS = path.join(ROOT, "transcripts"); +await mkdir(path.join(CORPUS, "channels"), { recursive: true }); +const SETTINGS_FILE = path.join(ROOT, "settings.json"); +await writeFile(SETTINGS_FILE, JSON.stringify({ workers: [] })); +const TOKEN = "test-worker-token"; +// Set before anything that caches getPaths() or the token is first imported. +process.env.WORKER_TOKEN = TOKEN; +process.env.TRANSCRIPTS_DIR = CORPUS; +process.env.SETTINGS_FILE = SETTINGS_FILE; +const { POST } = await import("./route"); +const { DELETE } = await import("./[id]/route"); +const { GET: eventsGET } = await import("./[id]/events/route"); +const { GET: resultGET } = await import("./[id]/result/route"); +test.after(() => rm(ROOT, { recursive: true, force: true })); + +const AUTH = { authorization: `Bearer ${TOKEN}` }; +const BASE = "http://localhost/api/worker/unit"; + +function post(body: unknown, headers: Record<string, string> = AUTH) { + return POST( + new Request(BASE, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function byId(id: string) { + return { params: Promise.resolve({ id }) }; +} + +test("the unit endpoint enforces the bearer token and refuses non-kinds", async () => { + const noAuth = await post( + { op: "attribution-text", channelSlug: "c", videoId: "v", files: {} }, + {}, + ); + assert.equal(noAuth.status, 401); + + // download/transcription are ExternalOperations, not backfill kinds — the + // executor refuses them at the door. + for (const op of ["download", "transcription", "nonsense"]) { + const refused = await post({ + op, + channelSlug: "c", + videoId: "v", + files: {}, + target: {}, + }); + assert.equal(refused.status, 400, op); + } +}); + +test("an attribution unit round-trips against a scratch corpus and a stub ollama", async () => { + // A fake ollama the EXECUTOR's injected appConfig.baseUrl points at. The + // /api/chat reply names one speaker, in the schema the turn prompt pins. + const stub = http.createServer((req, res) => { + res.setHeader("content-type", "application/json"); + if (req.url?.startsWith("/api/tags")) { + res.end(JSON.stringify({ models: [{ name: "stub-model" }] })); + return; + } + // Drain the request, then answer as ollama would. + req.resume(); + req.on("end", () => { + res.end( + JSON.stringify({ + model: "stub-model", + message: { + content: JSON.stringify({ + turns: [{ start: "00:00:01", speaker: "Host" }], + }), + }, + }), + ); + }); + }); + await new Promise<void>((resolve) => stub.listen(0, "127.0.0.1", resolve)); + const stubUrl = `http://127.0.0.1:${(stub.address() as AddressInfo).port}`; + + try { + const cues = { + version: 1, + id: "unitvid1", + title: "Unit test video", + channel: "unit-chan", + duration: 9, + cues: [ + { start: 0, end: 4, text: "hello there" }, + { start: 4, end: 9, text: "general kenobi" }, + ], + }; + const b64 = (s: string) => Buffer.from(s).toString("base64"); + const res = await post({ + op: "attribution-text", + channelSlug: "unit-chan", + videoId: "unitvid1", + files: { + "metadata.info.json": b64( + JSON.stringify({ id: "unitvid1", title: "Unit test video", duration: 9 }), + ), + "transcript.json": b64(JSON.stringify({ transcription: [] })), + // Materialized LAST by the executor whatever this map's order is — + // the mtime freshness gate depends on it. + "transcript.cues.json": b64(JSON.stringify(cues)), + }, + target: {}, + config: { + // The primary's identity, injected. Without this the executor's + // default settings (attribution disabled) would fail the job loudly. + attribution: { + enabled: true, + appId: "ollama-direct", + model: "stub-model", + diarizedEnabled: false, + textOnlyEnabled: true, + promptVersion: 2, + }, + appConfig: { model: "stub-model", baseUrl: stubUrl, numCtx: 8192 }, + context: { hash: "none" }, + }, + }); + assert.equal(res.status, 202); + const { remoteJobId } = (await res.json()) as { remoteJobId: string }; + assert.ok(remoteJobId); + + const deadline = Date.now() + 30_000; + let status = ""; + while (Date.now() < deadline) { + const ev = await eventsGET( + new Request(`${BASE}/${remoteJobId}/events`, { headers: AUTH }), + byId(remoteJobId), + ); + status = ((await ev.json()) as { status: string }).status; + if (status === "done" || status === "error") break; + await new Promise((r) => setTimeout(r, 100)); + } + assert.equal(status, "done"); + + const result = await resultGET( + new Request(`${BASE}/${remoteJobId}/result`, { headers: AUTH }), + byId(remoteJobId), + ); + assert.equal(result.status, 200); + const body = (await result.json()) as { + outcome: string; + files: Record<string, string>; + }; + assert.equal(body.outcome, "done"); + const record = JSON.parse(body.files["attribution.json"]) as { + speakers: Array<{ label: string }>; + provenance: { method: string; model: string }; + }; + assert.equal(record.speakers[0]?.label, "Host"); + assert.equal(record.provenance.method, "text-only"); + assert.equal(record.provenance.model, "stub-model"); + + // Cleanup removes the scratch corpus; the result then 404s. + const del = await DELETE( + new Request(`${BASE}/${remoteJobId}`, { method: "DELETE", headers: AUTH }), + byId(remoteJobId), + ); + assert.equal(del.status, 200); + const gone = await resultGET( + new Request(`${BASE}/${remoteJobId}/result`, { headers: AUTH }), + byId(remoteJobId), + ); + assert.equal(gone.status, 404); + } finally { + await new Promise<void>((resolve) => stub.close(() => resolve())); + } +}); diff --git a/editor/e2e/audio-check-classifier.spec.ts b/editor/e2e/audio-check-classifier.spec.ts @@ -1,70 +0,0 @@ -// Pure-function tests for the ffmpeg probe-result classifier. These don't -// need the dev server, fixtures, or a browser — but the project uses -// Playwright for everything, so they live here too. - -import { test, expect } from "@playwright/test"; -import { classifyFfmpegProbe } from "../../common/ytdlp/ffmpegStreamClassify"; - -test.describe("classifyFfmpegProbe", () => { - test("exit 0 with empty stderr → clean", () => { - expect(classifyFfmpegProbe(0, "")).toBe("clean"); - expect(classifyFfmpegProbe(0, "\n \t\n")).toBe("clean"); - }); - - test("exit 0 with 'partial file' stderr → partial", () => { - expect( - classifyFfmpegProbe( - 0, - "[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x1234] stream 1, offset 0x10483924: partial file\n", - ), - ).toBe("partial"); - }); - - test("exit 0 with many decoder errors and no 'partial file' → malformed", () => { - // ffmpeg can exit 0 even when the av_codec layer rejects hundreds of - // packets — the encoder keeps producing output from whatever decoded. - // A wall of "Error submitting packet to decoder" lines without a - // "partial file" demuxer warning is mid-stream corruption, not a - // clean truncation. - const aacStorm = Array.from( - { length: 50 }, - (_, i) => - `[aac @ 0x1] channel element ${i % 3}.${i % 16} is not allocated\n` + - `[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n`, - ).join(""); - expect(classifyFfmpegProbe(0, aacStorm)).toBe("malformed"); - }); - - test("exit 0 with many decoder errors AND 'partial file' → malformed (corruption wins over truncation)", () => { - const aacStormPlusPartial = - Array.from( - { length: 50 }, - () => - `[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n`, - ).join("") + - "[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x4] stream 1, offset 0x1626f8a: partial file\n"; - expect(classifyFfmpegProbe(0, aacStormPlusPartial)).toBe("malformed"); - }); - - test("exit 0 with a small tail of decoder errors AND 'partial file' → partial", () => { - // Truncated containers often emit a couple of trailing decoder errors - // as the encoder eats the last partial packets. Below threshold, the - // file is still classifiable as partial. - const tail = - "[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n" + - "[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n" + - "[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x4] stream 1, offset 0x1626f8a: partial file\n"; - expect(classifyFfmpegProbe(0, tail)).toBe("partial"); - }); - - test("non-zero exit → malformed (regardless of stderr)", () => { - expect( - classifyFfmpegProbe( - 1, - "[aac @ 0x1] Sample rate index in program config element does not match the sample rate index configured by the container.\n", - ), - ).toBe("malformed"); - expect(classifyFfmpegProbe(2, "")).toBe("malformed"); - expect(classifyFfmpegProbe(null, "killed by signal")).toBe("malformed"); - }); -}); diff --git a/editor/e2e/view-route.spec.ts b/editor/e2e/view-route.spec.ts @@ -8,96 +8,17 @@ import { resetData } from "./helpers"; // REWRITES in next.config.ts — server-internal, so a pinned widget and the // dashboard keep polling exactly what they always polled. // -// The ~30 assertions the rest of the suite makes at the old paths are that -// remap's real regression test; nothing there was edited. What this spec adds -// is the part those cannot see: that each old path and its new twin return the -// SAME BODY, that an unknown view name 404s instead of 500ing, that none of -// this wants a credential, and that the query string survives the rewrite — -// which is the whole of `/api/pulse?rev=`. - -// The fields that move between two back-to-back calls. Everything else in these -// payloads is read from disk or from in-memory state that does not move in an -// idle fixture, so it is compared verbatim. Dotted keys reach one level down. -const VOLATILE: Record<string, string[]> = { - // `builtAt` is Date.now() at build time. `disk.freeBytes` is a live statfs: - // it is null only while the fixture's disk gate is off (minFreeDiskGB: 0). - "/api/jobs/active": ["builtAt", "disk.freeBytes"], - // `now` is stamped so the console can age its rows client-side. - "/api/scheduler/status": ["now"], -}; - -const PAIRS: Array<[string, string]> = [ - ["/api/pulse", "/api/view/pulse"], - ["/api/jobs/active", "/api/view/activeJobs"], - ["/api/workers", "/api/view/workers"], - ["/api/auto-queue/status", "/api/view/autoQueueStatus"], - ["/api/scheduler/status", "/api/view/schedulerStatus"], - ["/api/widget/sync", "/api/view/widgetSync"], - ["/api/widget/actionable", "/api/view/widgetActionable"], - ["/api/widget/cleanable", "/api/view/cleanable"], -]; - -function strip(body: unknown, keys: string[]): unknown { - if (!body || typeof body !== "object") return body; - const copy = { ...(body as Record<string, unknown>) }; - for (const key of keys) { - const [head, tail] = key.split("."); - if (tail === undefined) { - delete copy[head]; - } else if (copy[head] && typeof copy[head] === "object") { - const inner = { ...(copy[head] as Record<string, unknown>) }; - delete inner[tail]; - copy[head] = inner; - } - } - return copy; -} +// The rewrite table, the dispatcher's 404s and the presets route are unit +// tests now (app/api/view/[name]/route.test.ts), and the ~30 assertions the +// rest of the suite makes at the old paths are the remap's runtime regression +// test. What stays here is the one thing only a running Next can show: that a +// rewrite carries the QUERY STRING, which is the whole of `/api/pulse?rev=`. test.describe("/api/view/[name]", () => { test.beforeEach(async () => { await resetData("channel-with-counts"); }); - for (const [oldPath, viewPath] of PAIRS) { - test(`${oldPath} and ${viewPath} are the same endpoint`, async ({ - request, - }) => { - const before = await request.get(oldPath); - const after = await request.get(viewPath); - expect(before.status(), `${oldPath} status`).toBe(200); - expect(after.status(), `${viewPath} status`).toBe(200); - - const keys = VOLATILE[oldPath] ?? []; - expect(strip(await after.json(), keys)).toEqual( - strip(await before.json(), keys), - ); - }); - } - - test("an unknown view name is 404, not 500", async ({ request }) => { - for (const name of ["nope", "Pulse", "activejobs", "presets"]) { - const res = await request.get(`/api/view/${name}`); - expect(res.status(), `/api/view/${name}`).toBe(404); - } - }); - - // The dispatcher has no guard by design (these are read-only polls), but the - // harness routes DO — and they must not be reachable through it. - test("a test-harness name is not a view", async ({ request }) => { - const res = await request.get("/api/view/invalidate-cache"); - expect(res.status()).toBe(404); - }); - - // /api/widget/presets is a menu fetch on open, not a poll: it is not a view, - // it keeps its own route, and nothing here shadows it. - test("/api/widget/presets is untouched", async ({ request }) => { - const res = await request.get("/api/widget/presets"); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(Array.isArray(body.builtIn)).toBe(true); - expect(Array.isArray(body.saved)).toBe(true); - }); - test("the rev query survives the rewrite", async ({ request }) => { const seed = await (await request.get("/api/view/pulse")).json(); expect(seed.changed).toBe(true); diff --git a/editor/e2e/worker-unit.spec.ts b/editor/e2e/worker-unit.spec.ts @@ -1,170 +0,0 @@ -// Unit-executor protocol (/api/worker/unit) — the generalisation of the -// remote-transcription protocol to backfill kinds. The test server runs with -// WORKER_TOKEN set (see package.json dev:test), so the endpoints are live. -// Three angles: -// 1. Auth + the door guard (only backfill KINDS are accepted — download and -// transcription are refused, which is what keeps download politeness -// single-machine). -// 2. A full round trip: an attribution-text unit whose model calls land on an -// ollama STUB started inside this test — proving the scratch-corpus -// materialization (cues written last passes the mtime freshness gate), the -// config injection, and the result pull, with no real model anywhere. -// 3. Cleanup: DELETE removes the scratch and the result 404s. - -import http from "node:http"; -import type { AddressInfo } from "node:net"; -import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; -import { baseUrl } from "./baseUrl"; - -const TOKEN = "test-worker-token"; -const AUTH = { authorization: `Bearer ${TOKEN}` }; - -test.beforeEach(async () => { - await resetData("empty"); -}); - -test("unit endpoint enforces the bearer token and refuses non-kinds", async ({ - request, -}) => { - const noAuth = await request.post(`${baseUrl}/api/worker/unit`, { - data: { op: "attribution-text", channelSlug: "c", videoId: "v", files: {} }, - }); - expect(noAuth.status()).toBe(401); - - // download/transcription are ExternalOperations, not backfill kinds — the - // executor refuses them at the door. - for (const op of ["download", "transcription", "nonsense"]) { - const refused = await request.post(`${baseUrl}/api/worker/unit`, { - headers: AUTH, - data: { op, channelSlug: "c", videoId: "v", files: {}, target: {} }, - }); - expect(refused.status(), op).toBe(400); - } -}); - -test("an attribution unit round-trips against a scratch corpus and a stub ollama", async ({ - request, -}) => { - test.setTimeout(60_000); - // A fake ollama the EXECUTOR's injected appConfig.baseUrl points at. The - // /api/chat reply names one speaker, in the schema the turn prompt pins. - const stub = http.createServer((req, res) => { - res.setHeader("content-type", "application/json"); - if (req.url?.startsWith("/api/tags")) { - res.end(JSON.stringify({ models: [{ name: "stub-model" }] })); - return; - } - // Drain the request, then answer as ollama would. - req.resume(); - req.on("end", () => { - res.end( - JSON.stringify({ - model: "stub-model", - message: { - content: JSON.stringify({ - turns: [{ start: "00:00:01", speaker: "Host" }], - }), - }, - }), - ); - }); - }); - await new Promise<void>((resolve) => stub.listen(0, "127.0.0.1", resolve)); - const stubUrl = `http://127.0.0.1:${(stub.address() as AddressInfo).port}`; - - try { - const cues = { - version: 1, - id: "unitvid1", - title: "Unit test video", - channel: "unit-chan", - duration: 9, - cues: [ - { start: 0, end: 4, text: "hello there" }, - { start: 4, end: 9, text: "general kenobi" }, - ], - }; - const b64 = (s: string) => Buffer.from(s).toString("base64"); - const post = await request.post(`${baseUrl}/api/worker/unit`, { - headers: AUTH, - data: { - op: "attribution-text", - channelSlug: "unit-chan", - videoId: "unitvid1", - files: { - "metadata.info.json": b64( - JSON.stringify({ id: "unitvid1", title: "Unit test video", duration: 9 }), - ), - "transcript.json": b64(JSON.stringify({ transcription: [] })), - // Materialized LAST by the executor whatever this map's order is — - // the mtime freshness gate depends on it. - "transcript.cues.json": b64(JSON.stringify(cues)), - }, - target: {}, - config: { - // The primary's identity, injected. Without this the executor's - // default settings (attribution disabled) would fail the job loudly. - attribution: { - enabled: true, - appId: "ollama-direct", - model: "stub-model", - diarizedEnabled: false, - textOnlyEnabled: true, - promptVersion: 2, - }, - appConfig: { model: "stub-model", baseUrl: stubUrl, numCtx: 8192 }, - context: { hash: "none" }, - }, - }, - }); - expect(post.status()).toBe(202); - const { remoteJobId } = await post.json(); - expect(remoteJobId).toBeTruthy(); - - await expect - .poll( - async () => { - const r = await request.get( - `${baseUrl}/api/worker/unit/${remoteJobId}/events`, - { headers: AUTH }, - ); - return ((await r.json()) as { status: string }).status; - }, - { timeout: 30_000 }, - ) - .toBe("done"); - - const result = await request.get( - `${baseUrl}/api/worker/unit/${remoteJobId}/result`, - { headers: AUTH }, - ); - expect(result.status()).toBe(200); - const body = (await result.json()) as { - outcome: string; - files: Record<string, string>; - }; - expect(body.outcome).toBe("done"); - const record = JSON.parse(body.files["attribution.json"]) as { - speakers: Array<{ label: string }>; - provenance: { method: string; model: string }; - }; - expect(record.speakers[0]?.label).toBe("Host"); - expect(record.provenance.method).toBe("text-only"); - expect(record.provenance.model).toBe("stub-model"); - - // Cleanup removes the scratch corpus; the result then 404s. - const del = await request.delete( - `${baseUrl}/api/worker/unit/${remoteJobId}`, - { headers: AUTH }, - ); - expect(del.status()).toBe(200); - const gone = await request.get( - `${baseUrl}/api/worker/unit/${remoteJobId}/result`, - { headers: AUTH }, - ); - expect(gone.status()).toBe(404); - } finally { - await new Promise<void>((resolve) => stub.close(() => resolve())); - } -}); diff --git a/editor/package.json b/editor/package.json @@ -9,7 +9,6 @@ "start:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs next start --port ${PORT:-3011}", "build": "next build", "start": "UV_THREADPOOL_SIZE=${UV_THREADPOOL_SIZE:-16} next start --port ${EDITOR_PORT:-3001}", - "lint": "eslint", "test": "tsx --test \"app/**/*.test.ts\"", "e2e": "node ../scripts/queue-lock.mjs --ports PORT:3011,EXPORT_PORT:3010,OLLAMA_STUB_PORT:11435 -- playwright test", "e2e:ui": "playwright test --ui" diff --git a/package.json b/package.json @@ -23,6 +23,8 @@ "wt": "node scripts/worktree.mjs", "e2e:sharded": "node scripts/run-sharded-e2e.mjs", "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs umtool/lib/annotations/*.test.mjs umtool/lib/articles/*.test.mjs", + "test": "pnpm -r --no-bail --no-sort --workspace-concurrency=1 run test; a=$?; pnpm run test:scripts; b=$?; [ $a -eq 0 ] && [ $b -eq 0 ]", + "typecheck": "pnpm -r --no-bail --no-sort --workspace-concurrency=1 exec tsc --noEmit", "lint": "pnpm --filter export run lint", "ops": "node scripts/archilyzer-ops.mjs" },