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:
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"
},