Step 11 of v1.12.0 plan (.claude/projects/2026-05-04-kb-update-fork-and-own/plan.md).
scripts/install-kb-cron.mjs lives at the scripts/ root (not inside
scripts/kb-update/) because it is a plugin-wide install tool, not part of
the KB-update pipeline itself. Reads the appropriate template from
scripts/kb-update/templates/, fills {{NODE_BIN}}, {{PLUGIN_ROOT}},
{{LOG_FILE}}, {{SCHEDULE_HOUR/MINUTE/DAY_OF_WEEK}} placeholders, writes
to the platform-specific scheduler dir, and registers the job:
macOS - launchctl bootstrap gui/<uid> <plist> (load -w fallback)
Linux - systemctl --user daemon-reload && enable --now <timer>
Windows - powershell -ExecutionPolicy Bypass -File <ps1> (beta)
Flags: --print-only, --target macos|linux|windows, --uninstall, --purge,
--node-bin, --claude-bin, --schedule "M H * * D" (default: Wed 04:23).
UID resolution for launchctl is guarded by process.getuid() POSIX-only
(undefined on Windows). MCP server presence in ~/.claude.json is
warning-only per brief Spørsmål 7. WSL detected via /proc/version.
Cross-OS rendering supported via --print-only --target <other>; install
on a non-host target rejects with explicit error.
11 subprocess + filesystem-snapshot tests in
tests/kb-update/test-install-cron.test.mjs verify --print-only produces
filled templates with no unsubstituted {{...}} placeholders, --print-only
writes nothing under HOME, --uninstall is idempotent on an empty HOME,
--schedule substitutes correctly, and invalid flags reject with non-zero
exit. Tests never invoke launchctl/systemctl/Register-ScheduledTask
against real schedulers.
Tests: 110/110 pass (was 99 before this step).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
207 lines
7.3 KiB
JavaScript
207 lines
7.3 KiB
JavaScript
// tests/kb-update/test-install-cron.test.mjs
|
|
// Subprocess + filesystem-snapshot tests for scripts/install-kb-cron.mjs
|
|
// (Step 11). Exercises --print-only across targets and verifies idempotent
|
|
// --uninstall. Never invokes launchctl/systemctl/Register-ScheduledTask
|
|
// against real schedulers; --print-only short-circuits before any
|
|
// side-effecting call, and --uninstall is exercised against an empty HOME
|
|
// where the install file simply does not exist.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, rmSync, readdirSync, existsSync } from 'node:fs';
|
|
import { tmpdir, platform as osPlatform } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SCRIPT = join(__dirname, '..', '..', 'scripts', 'install-kb-cron.mjs');
|
|
|
|
function mkSandbox() {
|
|
return mkdtempSync(join(tmpdir(), 'install-kb-cron-test-'));
|
|
}
|
|
|
|
function runInstall(args, env = {}) {
|
|
return spawnSync('node', [SCRIPT, ...args], {
|
|
env: { PATH: process.env.PATH, ...env },
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
function snapshotDir(dir) {
|
|
const out = [];
|
|
function walk(d) {
|
|
if (!existsSync(d)) return;
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(d, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const p = join(d, entry.name);
|
|
out.push(p);
|
|
if (entry.isDirectory()) walk(p);
|
|
}
|
|
}
|
|
walk(dir);
|
|
return out.sort();
|
|
}
|
|
|
|
function hostTarget() {
|
|
const p = osPlatform();
|
|
if (p === 'darwin') return 'macos';
|
|
if (p === 'linux') return 'linux';
|
|
if (p === 'win32') return 'windows';
|
|
return null;
|
|
}
|
|
|
|
test('--print-only --target macos: substituted plist with no unsubstituted placeholders', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(['--print-only', '--target', 'macos'], { HOME: home });
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
|
|
assert.match(r.stdout, /<key>Label<\/key>/);
|
|
assert.match(r.stdout, /<key>StartCalendarInterval<\/key>/);
|
|
assert.match(r.stdout, /<integer>3<\/integer>/, 'default day-of-week=3 (Wednesday)');
|
|
assert.match(r.stdout, /<integer>4<\/integer>/, 'default hour=4');
|
|
assert.match(r.stdout, /<integer>23<\/integer>/, 'default minute=23');
|
|
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--print-only --target linux: filled service+timer with [Unit] and OnCalendar=Wed', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(['--print-only', '--target', 'linux'], { HOME: home });
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.match(r.stdout, /\[Unit\]/);
|
|
assert.match(r.stdout, /\[Service\]/);
|
|
assert.match(r.stdout, /\[Timer\]/);
|
|
assert.match(r.stdout, /OnCalendar=Wed/);
|
|
assert.match(r.stdout, /ExecStart=/);
|
|
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--print-only --target windows: Register-ScheduledTask + InteractiveToken', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(['--print-only', '--target', 'windows'], { HOME: home });
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.match(r.stdout, /Register-ScheduledTask/);
|
|
assert.match(r.stdout, /InteractiveToken/);
|
|
assert.match(r.stdout, /-DaysOfWeek\s+Wednesday/);
|
|
assert.doesNotMatch(r.stdout, /\{\{[A-Z_]+\}\}/, 'no unsubstituted {{...}} placeholders');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--print-only writes no files (HOME snapshot before/after equal)', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const before = snapshotDir(home);
|
|
const r = runInstall(['--print-only', '--target', 'macos'], { HOME: home });
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
const after = snapshotDir(home);
|
|
assert.deepEqual(after, before, 'HOME must not be touched in --print-only mode');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--print-only --target linux writes no files in HOME', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const before = snapshotDir(home);
|
|
const r = runInstall(['--print-only', '--target', 'linux'], { HOME: home });
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
const after = snapshotDir(home);
|
|
assert.deepEqual(after, before, 'HOME must not be touched in --print-only mode (linux target)');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--uninstall is idempotent (exit 0 with nothing installed)', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const target = hostTarget() || 'macos';
|
|
const r = runInstall(['--uninstall', '--target', target], { HOME: home });
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--uninstall --target macos on empty HOME: idempotent (no plist, no launchctl call)', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(['--uninstall', '--target', 'macos'], { HOME: home });
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.match((r.stdout || '') + (r.stderr || ''), /nothing to remove|not installed/i);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--schedule with custom cron expr substitutes correctly into plist', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(
|
|
['--print-only', '--target', 'macos', '--schedule', '15 7 * * 5'],
|
|
{ HOME: home },
|
|
);
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.match(r.stdout, /<integer>15<\/integer>/, 'minute=15');
|
|
assert.match(r.stdout, /<integer>7<\/integer>/, 'hour=7');
|
|
assert.match(r.stdout, /<integer>5<\/integer>/, 'day-of-week=5 (Friday)');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('invalid --target rejects with non-zero exit', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(['--print-only', '--target', 'bogus'], { HOME: home });
|
|
assert.notEqual(r.status, 0);
|
|
assert.match((r.stderr || '') + (r.stdout || ''), /target|invalid|unsupported/i);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('invalid --schedule rejects with non-zero exit', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(
|
|
['--print-only', '--target', 'macos', '--schedule', 'not a cron'],
|
|
{ HOME: home },
|
|
);
|
|
assert.notEqual(r.status, 0);
|
|
assert.match((r.stderr || '') + (r.stdout || ''), /schedule|cron|invalid/i);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--node-bin override appears in substituted plist', () => {
|
|
const home = mkSandbox();
|
|
try {
|
|
const r = runInstall(
|
|
['--print-only', '--target', 'macos', '--node-bin', '/opt/custom/bin/node'],
|
|
{ HOME: home },
|
|
);
|
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
assert.match(r.stdout, /\/opt\/custom\/bin\/node/);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|