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"); }); });