import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; // Resolve the package root (scripts/editions) so the subprocess `src/cli.ts` path + // the `tsx` loader resolve regardless of the runner's cwd. const editionsDir = fileURLToPath(new URL("..", import.meta.url)); function run(args: string[], dataRoot?: string): { status: number | null; stdout: string; stderr: string } { const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { encoding: "utf8", cwd: editionsDir, env: dataRoot ? { ...process.env, LINKEDIN_STUDIO_DATA: dataRoot } : process.env, }); return { status: res.status, stdout: res.stdout, stderr: res.stderr }; } const T1 = "2026-07-21T09:30:00.000Z"; const T2 = "2026-07-24T09:30:00.000Z"; /** A scratch series root with a realistic edition-state.json, plus a scratch data dir. */ function fixture(): { dir: string; statePath: string; dataRoot: string; seriesRoot: string } { const dir = mkdtempSync(join(tmpdir(), "editions-cli-register-")); const seriesRoot = join(dir, "series", "seres"); const statePath = join(seriesRoot, "linkedin", "edition-state.json"); mkdirSync(join(seriesRoot, "linkedin"), { recursive: true }); writeFileSync( statePath, JSON.stringify( { schemaVersion: 1, series: { slug: "seres", title: "Seres" }, currentArticle: "05", currentPhase: "skeleton-pitch", articles: { "05": { title: "Da pipelinen brøt sammen", phase: "skeleton-pitch", phaseLog: [] } }, }, null, 2, ) + "\n", "utf8", ); return { dir, statePath, dataRoot: join(dir, "data"), seriesRoot }; } describe("editions CLI — register upsert/list/complete round-trip (N12 AC)", () => { test("upsert -> list -> complete round-trips through a real register in a scratch data dir", () => { const { dir, statePath, dataRoot, seriesRoot } = fixture(); try { // 1. a phase transition mirrors the edition into the register const up = run( ["register-upsert", "--edition-state", statePath, "--next", "Step 3a — spine prose", "--at", T1], dataRoot, ); assert.equal(up.status, 0, up.stderr); const registerPath = join(dataRoot, "editions", "register.json"); assert.ok(existsSync(registerPath), "register-upsert must create the register under ${DATA}/editions/"); // 2. list surfaces it without opening a single edition-state.json const listed = run(["register-list", "--json"], dataRoot); assert.equal(listed.status, 0, listed.stderr); const rows = JSON.parse(listed.stdout); assert.equal(rows.length, 1); assert.equal(rows[0].series, "seres"); assert.equal(rows[0].editionId, "05"); assert.equal(rows[0].title, "Da pipelinen brøt sammen"); assert.equal(rows[0].currentPhase, "skeleton-pitch"); assert.equal(rows[0].nextAction, "Step 3a — spine prose"); assert.equal(rows[0].path, seriesRoot, "the series root is derived from the state-file path"); assert.equal(rows[0].startedAt, T1); assert.equal(rows[0].status, "active"); // 3. complete closes the row and takes it out of the in-flight view const done = run(["register-complete", "--series", "seres", "--edition", "05", "--at", T2], dataRoot); assert.equal(done.status, 0, done.stderr); assert.equal(JSON.parse(run(["register-list", "--json"], dataRoot).stdout).length, 0); const all = JSON.parse(run(["register-list", "--json", "--all"], dataRoot).stdout); assert.equal(all.length, 1); assert.equal(all[0].status, "complete"); assert.equal(all[0].completedAt, T2); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("the same upsert also appends the phaseLog entry — telemetry is not a separate step", () => { const { dir, statePath, dataRoot } = fixture(); try { assert.equal(run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot).status, 0); const state = JSON.parse(readFileSync(statePath, "utf8")); assert.deepEqual(state.articles["05"].phaseLog, [{ phase: "skeleton-pitch", completedAt: T1 }]); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("a repeated transition is idempotent in both the log and the register", () => { const { dir, statePath, dataRoot } = fixture(); try { run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot); run(["register-upsert", "--edition-state", statePath, "--at", T2], dataRoot); const state = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(state.articles["05"].phaseLog.length, 1); const rows = JSON.parse(run(["register-list", "--json"], dataRoot).stdout); assert.equal(rows.length, 1); assert.equal(rows[0].startedAt, T1, "lead time is anchored at the first transition"); assert.equal(rows[0].updatedAt, T2); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("the human view shows phase, next action and days in flight — the WIP answer without opening files", () => { const { dir, statePath, dataRoot } = fixture(); try { run(["register-upsert", "--edition-state", statePath, "--next", "Step 3a — spine prose", "--at", T1], dataRoot); const out = run(["register-list", "--now", T2], dataRoot); assert.equal(out.status, 0, out.stderr); assert.match(out.stdout, /seres/); assert.match(out.stdout, /05/); assert.match(out.stdout, /skeleton-pitch/); assert.match(out.stdout, /Step 3a — spine prose/); assert.match(out.stdout, /3(\.0)? day/, "days in flight is the lead-time signal (T1 -> T2 = 3 days)"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("an empty register says so plainly instead of printing nothing", () => { const { dir, dataRoot } = fixture(); try { const out = run(["register-list"], dataRoot); assert.equal(out.status, 0, out.stderr); assert.match(out.stdout, /no editions in flight/i); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("--register overrides the data-dir default", () => { const { dir, statePath, dataRoot } = fixture(); try { const elsewhere = join(dir, "elsewhere", "register.json"); assert.equal( run(["register-upsert", "--edition-state", statePath, "--register", elsewhere, "--at", T1], dataRoot).status, 0, ); assert.ok(existsSync(elsewhere)); assert.ok(!existsSync(join(dataRoot, "editions", "register.json"))); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("register-upsert without --edition-state is a usage error (exit 2)", () => { const { dir, dataRoot } = fixture(); try { const out = run(["register-upsert", "--next", "Step 3a"], dataRoot); assert.equal(out.status, 2); assert.match(out.stderr, /--edition-state/); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("register-complete on an unknown edition fails loudly instead of silently succeeding", () => { const { dir, statePath, dataRoot } = fixture(); try { run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot); const out = run(["register-complete", "--series", "seres", "--edition", "99", "--at", T2], dataRoot); assert.notEqual(out.status, 0); assert.match(out.stderr, /99/); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("a malformed edition-state is refused at the edge, not mirrored", () => { const { dir, statePath, dataRoot } = fixture(); try { writeFileSync(statePath, JSON.stringify({ schemaVersion: 1, series: { slug: "seres" } }), "utf8"); const out = run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot); assert.equal(out.status, 2); assert.match(out.stderr, /currentArticle/); } finally { rmSync(dir, { recursive: true, force: true }); } }); });