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("Label") && p.includes("com.linkedin-studio.trends.daily"), "Label");
assert.ok(p.includes("ProgramArguments") && p.includes("/bin/bash") && p.includes("run-daily.sh"), "ProgramArguments");
assert.ok(p.includes("--pillars") && p.includes("ai,gov"), "the args are rendered as entries");
assert.ok(p.includes("StartCalendarInterval"), "StartCalendarInterval");
assert.match(p, /Hour<\/key>\s*7<\/integer>/, "Hour 7");
assert.match(p, /Minute<\/key>\s*30<\/integer>/, "Minute 30");
assert.ok(p.includes("StandardOutPath") && p.includes("StandardErrorPath"), "Std*Path");
assert.ok(p.includes("/data/trends/cron.log"), "Std*Path point at the resolved cron.log");
assert.ok(p.includes("EnvironmentVariables") && p.includes("NODE_BIN") && p.includes("LINKEDIN_STUDIO_DATA"), "env rendered");
assert.ok(p.includes("WorkingDirectory") && 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(""), "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");
});
});