linkedin-studio/scripts/trends/tests/run-daily.test.ts
Kjell Tore Guttormsen 3276e44dbf feat(linkedin-studio): RE-R3c — autonomous trigger (scheduler + headless entry) [skip-docs]
Closes research-engine hulls (1) no autonomous trigger + (6) no headless entry.
Makes the daily research loop closed + headless: deterministic-brief-only (C1),
print-first (C2 — the tool never runs launchctl or the cron table; --install writes
only the inert launchd plist file).

- NEW scripts/trends/src/schedule.ts — pure string emitters (launchd plist + cron-line +
  install/uninstall instructions + defaultLabel). No clock/fs/env/AI; byte-deterministic.
- NEW scripts/trends/run-daily.sh — bash-3.2 headless wrapper: resolves node, cd's into the
  package so tsx resolves, logs via the data-path twin seam; runs the deterministic brief and
  appends one compact cron.log line per fire. The (e) AI-capture seam is documented, not built.
- EDIT cli.ts — schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
  [--platform auto|launchd|cron] [--install|--uninstall] [--store <p>]; print-first, no new
  exit code; logPath anchored to dirname(defaultStorePath()) (not the --store override).
- WIRE trend-spotter.md (one prose line) + README (scheduler + wrapper + the C1 boundary).
- Gate: TRENDS_TESTS_FLOOR 171->192, ASSERT_BASELINE_FLOOR 105->111, new UNCONDITIONAL
  Section 16l (6 deps-absent greps + non-vacuity self-test), header-enum + floor-history append.

TDD two-phase RED -> GREEN. trends 192/192, gate 126/0, hook-suite 139/0 (untouched), plutil
-lint OK. No schema change (SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1). Counts 29/19/27 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 11:00:59 +02:00

134 lines
6.4 KiB
TypeScript

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<string, string> = {},
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 <dir>/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 `<ts> exit=0 <compact-json>`");
} 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 <data>/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 });
}
});
});