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, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { defaultStorePath } from "../src/store.js"; // The hooks `.mjs` twin of the data-root seam — SC8 binds it into the consistency check. import { getDataRoot } from "../../../hooks/scripts/data-root.mjs"; // Package root (scripts/trends) so the wrapper path + `tsx` resolution are cwd-independent. const trendsDir = fileURLToPath(new URL("..", import.meta.url)); const wrapper = join(trendsDir, "run-daily.sh"); const today = new Date().toISOString().slice(0, 10); // Invoke THROUGH `bash` (never a direct executable spawn): an ABSENT wrapper exits 127 // (a clean assertion-RED), where a direct exec of a missing file would throw ENOENT / // status:null (a module-not-found-class failure). Folded — plan-critic #7. function runWrapper( args: string[], env: Record = {}, cwd: string = trendsDir, ): { status: number | null; stdout: string; stderr: string } { const res = spawnSync("bash", [wrapper, ...args], { input: "", encoding: "utf8", cwd, env: { ...process.env, ...env }, }); return { status: res.status, stdout: res.stdout, stderr: res.stderr }; } describe("trends headless wrapper — run-daily.sh (RE-R3c / SC7, SC8)", () => { const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); // One temp dir is BOTH the LINKEDIN_STUDIO_DATA root (so cron.log lands at /trends/cron.log) // AND holds the seeded store + brief out-dir, so the real per-user data dir is never touched. function fixture(): { dir: string; store: string; out: string } { const dir = mkdtempSync(join(tmpdir(), "r3c-wrap-")); const store = join(dir, "s.json"); writeFileSync( store, JSON.stringify({ schemaVersion: 2, trends: [ { id: "a", title: "Fresh Match", url: "https://e/a", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai", "gov"], }, ], }), ); return { dir, store, out: join(dir, "mb") }; } test("SC7: seeded store → dated brief .md written + exactly one compact cron.log line + exit 0", () => { const { dir, store, out } = fixture(); try { const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir, }); assert.equal(status, 0, "the deterministic brief exits 0"); assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief .md is written"); const log = readFileSync(join(dir, "trends", "cron.log"), "utf8"); const lines = log.split("\n").filter((l) => l.trim().length > 0); assert.equal(lines.length, 1, "exactly one log line (the pretty-printed brief json compacted to one line)"); assert.match(lines[0], /^\S+ exit=0 /, "the line is ` exit=0 `"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("SC7: a second same-day run → byte-identical .md, surfacedCount not double-counted, second log line", () => { const { dir, store, out } = fixture(); try { assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0); const md1 = readFileSync(join(out, `${today}.md`), "utf8"); assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0); const md2 = readFileSync(join(out, `${today}.md`), "utf8"); assert.equal(md1, md2, "the same-day re-render is byte-identical"); const persisted = JSON.parse(readFileSync(store, "utf8")); assert.equal(persisted.trends[0].surfacedCount, 1, "surfacedCount stays 1 (R3b per-day idempotency)"); const lines = readFileSync(join(dir, "trends", "cron.log"), "utf8").split("\n").filter((l) => l.trim().length > 0); assert.equal(lines.length, 2, "two runs → two log lines"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("SC7: CWD-independent — runs from an unrelated cwd (the wrapper's `cd \"$DIR\"` resolves tsx)", () => { const { dir, store, out } = fixture(); const otherCwd = mkdtempSync(join(tmpdir(), "r3c-cwd-")); try { const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }, otherCwd); assert.equal(status, 0, "the wrapper resolves tsx from a cwd that is not the package dir"); assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief is still written"); } finally { rmSync(dir, { recursive: true, force: true }); rmSync(otherCwd, { recursive: true, force: true }); } }); test("SC8: data-path twin consistency — wrapper log dir == dirname(defaultStorePath()) == getDataRoot('trends')", () => { const { dir, store, out } = fixture(); const saved = process.env.LINKEDIN_STUDIO_DATA; try { runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }); const wrapperLogDir = join(dir, "trends"); // where the wrapper actually wrote cron.log assert.ok(existsSync(join(wrapperLogDir, "cron.log")), "the wrapper wrote into /trends"); // The TS store twin + the hooks `.mjs` twin, resolved under the SAME override. process.env.LINKEDIN_STUDIO_DATA = dir; assert.equal(dirname(defaultStorePath()), wrapperLogDir, "TS dirname(defaultStorePath()) == wrapper log dir"); assert.equal(getDataRoot("trends"), wrapperLogDir, "hooks getDataRoot('trends') == wrapper log dir"); // Override-independence: a different root still resolves the two TS/JS twins identically. const other = mkdtempSync(join(tmpdir(), "r3c-twin-")); process.env.LINKEDIN_STUDIO_DATA = other; assert.equal(dirname(defaultStorePath()), getDataRoot("trends"), "the twins agree for any override root"); rmSync(other, { recursive: true, force: true }); } finally { if (saved === undefined) delete process.env.LINKEDIN_STUDIO_DATA; else process.env.LINKEDIN_STUDIO_DATA = saved; rmSync(dir, { recursive: true, force: true }); } }); });