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
This commit is contained in:
parent
b43757462b
commit
3276e44dbf
9 changed files with 768 additions and 9 deletions
|
|
@ -439,3 +439,164 @@ describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)",
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("trends CLI — schedule subcommand (RE-R3c / autonomous trigger, print-first)", () => {
|
||||
// schedule is flag-driven + print-first. Capture stdout AND stderr (usage → stderr); override HOME
|
||||
// (the ~/Library/LaunchAgents target) + LINKEDIN_STUDIO_DATA (the cron.log seam) into a temp dir so
|
||||
// a real HOME is never touched. (os.homedir() respects $HOME on POSIX — verified.)
|
||||
function runSched(
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
): { status: number | null; stdout: string; stderr: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "schedule", ...args], {
|
||||
input: "",
|
||||
encoding: "utf8",
|
||||
cwd: trendsDir,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
||||
}
|
||||
const tmpHome = () => mkdtempSync(join(tmpdir(), "sched-home-"));
|
||||
|
||||
// A dependency-free well-formedness check: tokenize element tags and assert they nest/balance
|
||||
// (the `<?xml?>` PI and `<!DOCTYPE>` are skipped — neither starts with a letter after `<`).
|
||||
// SC1 asserts balance + key-completeness; `plutil -lint` is the deps-present manual check (Step 7).
|
||||
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; // self-closing (e.g. <false/>)
|
||||
if (m[1] === "/") {
|
||||
if (stack.pop() !== m[2]) return false;
|
||||
} else {
|
||||
stack.push(m[2]);
|
||||
}
|
||||
}
|
||||
return stack.length === 0;
|
||||
}
|
||||
|
||||
test("SC1: --platform launchd --at 07:30 --print → key-complete, well-formed plist, exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(
|
||||
["--pillars", "ai,gov", "--platform", "launchd", "--at", "07:30", "--print"],
|
||||
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
|
||||
);
|
||||
assert.equal(status, 0);
|
||||
assert.match(stdout, /<key>Label<\/key>\s*<string>com\.linkedin-studio\.trends\.daily<\/string>/);
|
||||
assert.ok(stdout.includes("<key>ProgramArguments</key>"), "ProgramArguments present");
|
||||
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
|
||||
assert.ok(stdout.includes("--pillars") && stdout.includes("ai,gov"), "carries the pillars");
|
||||
assert.match(stdout, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
|
||||
assert.match(stdout, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
|
||||
assert.ok(stdout.includes("<key>StandardOutPath</key>") && stdout.includes("<key>StandardErrorPath</key>"), "Std*Path present");
|
||||
assert.ok(stdout.includes("cron.log"), "Std*Path point at cron.log");
|
||||
assert.ok(stdout.includes("<key>EnvironmentVariables</key>"), "EnvironmentVariables present");
|
||||
assert.ok(stdout.includes("NODE_BIN") && stdout.includes("LINKEDIN_STUDIO_DATA"), "env carries NODE_BIN + data root");
|
||||
assert.ok(isBalancedXml(stdout), "the plist XML is balanced / well-formed");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC1: two --print runs (same args) are byte-identical (the emitter is pure)", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const a = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
const b = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(a.status, 0);
|
||||
assert.equal(a.stdout, b.stdout);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC2: --platform cron --at 07:30 --print → cron line + install recipe (string only), exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(
|
||||
["--pillars", "ai,gov", "--platform", "cron", "--at", "07:30", "--print"],
|
||||
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
|
||||
);
|
||||
assert.equal(status, 0);
|
||||
assert.match(stdout, /^30 7 \* \* \* /m, "the cron line fires at 07:30 daily");
|
||||
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
|
||||
assert.ok(stdout.includes("--pillars ai,gov"), "carries the pillars");
|
||||
assert.ok(stdout.includes(">> ") && stdout.includes("2>&1"), "redirects stdout+stderr to the log");
|
||||
assert.ok(stdout.includes("com.linkedin-studio.trends.daily"), "the label comment");
|
||||
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (a string the operator runs)");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC3: platform auto (no --platform) → launchd on darwin, cron elsewhere", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
if (process.platform === "darwin") {
|
||||
assert.ok(stdout.includes("<key>Label</key>"), "darwin auto → launchd plist");
|
||||
} else {
|
||||
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "non-darwin auto → cron line");
|
||||
}
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC4: --print writes nothing (temp-HOME LaunchAgents stays absent), exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
assert.ok(stdout.includes("<key>Label</key>"), "the plist is on stdout");
|
||||
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "--print creates no LaunchAgents dir");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC5: --install launchd → inert plist FILE written + launchctl printed (never run), exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
const plist = join(home, "Library", "LaunchAgents", "com.linkedin-studio.trends.daily.plist");
|
||||
assert.ok(existsSync(plist), "the inert plist file is written");
|
||||
const content = readFileSync(plist, "utf8");
|
||||
assert.ok(content.includes("<key>Label</key>") && content.includes("com.linkedin-studio.trends.daily"), "the file holds the plist");
|
||||
assert.ok(stdout.includes("launchctl bootstrap"), "the activation command is PRINTED (the tool never runs launchctl)");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC6: --install cron → line + install recipe printed, writes no file, exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "cron", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "the cron line is printed");
|
||||
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (never executed)");
|
||||
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "cron --install writes no plist");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC9: no --pillars / bad --at / bad --platform → exit 2; the fs is untouched", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
assert.equal(runSched([], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "no --pillars → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--at", "25:00"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 25:00 → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--at", "7:99"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 7:99 → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--at", "noon"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at noon → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--platform", "bogus"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--platform bogus → exit 2");
|
||||
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "a validation error writes nothing");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
134
scripts/trends/tests/run-daily.test.ts
Normal file
134
scripts/trends/tests/run-daily.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
120
scripts/trends/tests/schedule.test.ts
Normal file
120
scripts/trends/tests/schedule.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue