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
120 lines
5.6 KiB
TypeScript
120 lines
5.6 KiB
TypeScript
import { describe, test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
launchdPlist,
|
|
crontabLine,
|
|
installInstructions,
|
|
uninstallInstructions,
|
|
defaultLabel,
|
|
type ScheduleSpec,
|
|
} from "../src/schedule.js";
|
|
|
|
// A fully-resolved spec, exactly as the CLI builds it at generation time (every value injected —
|
|
// the emitters read no clock/fs/env). `env` is canonical: always { NODE_BIN, LINKEDIN_STUDIO_DATA }.
|
|
function launchdSpec(): ScheduleSpec {
|
|
return {
|
|
platform: "launchd",
|
|
label: "com.linkedin-studio.trends.daily",
|
|
nodeBin: "/usr/local/bin/node",
|
|
wrapperPath: "/repo/scripts/trends/run-daily.sh",
|
|
args: ["--pillars", "ai,gov", "--fresh-days", "7"],
|
|
hour: 7,
|
|
minute: 30,
|
|
logPath: "/data/trends/cron.log",
|
|
workingDir: "/repo/scripts/trends",
|
|
env: { NODE_BIN: "/usr/local/bin/node", LINKEDIN_STUDIO_DATA: "/data" },
|
|
};
|
|
}
|
|
const cronSpec = (): ScheduleSpec => ({ ...launchdSpec(), platform: "cron" });
|
|
|
|
function isBalancedXml(xml: string): boolean {
|
|
const stack: string[] = [];
|
|
const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(xml)) !== null) {
|
|
if (m[3] === "/") continue;
|
|
if (m[1] === "/") {
|
|
if (stack.pop() !== m[2]) return false;
|
|
} else {
|
|
stack.push(m[2]);
|
|
}
|
|
}
|
|
return stack.length === 0;
|
|
}
|
|
|
|
describe("schedule.ts — pure launchd plist emitter (SC1)", () => {
|
|
test("key-complete: Label, ProgramArguments, StartCalendarInterval(H/M), Std*Path, EnvironmentVariables", () => {
|
|
const p = launchdPlist(launchdSpec());
|
|
assert.ok(p.includes("<key>Label</key>") && p.includes("com.linkedin-studio.trends.daily"), "Label");
|
|
assert.ok(p.includes("<key>ProgramArguments</key>") && p.includes("/bin/bash") && p.includes("run-daily.sh"), "ProgramArguments");
|
|
assert.ok(p.includes("--pillars") && p.includes("ai,gov"), "the args are rendered as <string> entries");
|
|
assert.ok(p.includes("<key>StartCalendarInterval</key>"), "StartCalendarInterval");
|
|
assert.match(p, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
|
|
assert.match(p, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
|
|
assert.ok(p.includes("<key>StandardOutPath</key>") && p.includes("<key>StandardErrorPath</key>"), "Std*Path");
|
|
assert.ok(p.includes("/data/trends/cron.log"), "Std*Path point at the resolved cron.log");
|
|
assert.ok(p.includes("<key>EnvironmentVariables</key>") && p.includes("NODE_BIN") && p.includes("LINKEDIN_STUDIO_DATA"), "env rendered");
|
|
assert.ok(p.includes("<key>WorkingDirectory</key>") && p.includes("/repo/scripts/trends"), "WorkingDirectory");
|
|
assert.ok(p.includes("RunAtLoad"), "RunAtLoad declared");
|
|
});
|
|
|
|
test("well-formed: balanced tags + plist DOCTYPE wrapper", () => {
|
|
const p = launchdPlist(launchdSpec());
|
|
assert.ok(p.startsWith("<?xml"), "begins with the XML declaration");
|
|
assert.ok(p.includes("<!DOCTYPE plist"), "has the plist DOCTYPE");
|
|
assert.ok(p.trimEnd().endsWith("</plist>"), "closes the plist element");
|
|
assert.ok(isBalancedXml(p), "every element tag is balanced");
|
|
});
|
|
|
|
test("pure / deterministic: two calls are byte-identical", () => {
|
|
assert.equal(launchdPlist(launchdSpec()), launchdPlist(launchdSpec()));
|
|
});
|
|
});
|
|
|
|
describe("schedule.ts — pure cron line emitter (SC2, string only)", () => {
|
|
test("the line carries time, env prefix, /bin/bash, wrapper, args, log redirect, label comment", () => {
|
|
const line = crontabLine(cronSpec());
|
|
assert.match(line, /^30 7 \* \* \* /, "fires 07:30 daily");
|
|
assert.ok(line.includes("NODE_BIN=/usr/local/bin/node"), "the env prefix carries NODE_BIN");
|
|
assert.ok(line.includes("LINKEDIN_STUDIO_DATA=/data"), "the env prefix carries the data root");
|
|
assert.ok(line.includes("/bin/bash") && line.includes("run-daily.sh"), "invokes the wrapper via bash");
|
|
assert.ok(line.includes("--pillars ai,gov"), "carries the pillars");
|
|
assert.ok(line.includes(">> /data/trends/cron.log 2>&1"), "redirects stdout+stderr to the log");
|
|
assert.ok(line.trimEnd().endsWith("# com.linkedin-studio.trends.daily"), "ends with the label comment");
|
|
assert.ok(!line.includes("\n"), "a single line");
|
|
});
|
|
});
|
|
|
|
describe("schedule.ts — install / uninstall instruction strings", () => {
|
|
const plistTarget = "/home/u/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist";
|
|
|
|
test("install launchd: written-path + launchctl bootstrap recipe", () => {
|
|
const out = installInstructions(launchdSpec(), plistTarget);
|
|
assert.ok(out.includes(plistTarget), "names the written plist path");
|
|
assert.ok(out.includes("launchctl bootstrap"), "the activation recipe");
|
|
});
|
|
|
|
test("install cron: the crontab - install recipe carrying the line", () => {
|
|
const out = installInstructions(cronSpec());
|
|
assert.ok(out.includes("crontab -"), "the install recipe");
|
|
assert.ok(out.includes("run-daily.sh"), "carries the line to install");
|
|
});
|
|
|
|
test("uninstall launchd: bootout + rm the plist", () => {
|
|
const out = uninstallInstructions(launchdSpec(), plistTarget);
|
|
assert.ok(out.includes("launchctl bootout"), "the deactivation recipe");
|
|
assert.ok(out.includes(plistTarget), "removes the plist file");
|
|
});
|
|
|
|
test("uninstall cron: the line-removal recipe", () => {
|
|
const out = uninstallInstructions(cronSpec());
|
|
assert.ok(out.includes("crontab -") && out.includes("com.linkedin-studio.trends.daily"), "removes by the label comment");
|
|
});
|
|
});
|
|
|
|
describe("schedule.ts — defaultLabel (SC1/SC2 the plugin namespace)", () => {
|
|
test("is the reverse-DNS plugin namespace", () => {
|
|
assert.equal(defaultLabel(), "com.linkedin-studio.trends.daily");
|
|
});
|
|
});
|