// tests/kb-update/test-weekly-kb-cron-flags.test.mjs // Subprocess-based flag-parsing tests for scripts/kb-update/weekly-kb-cron.mjs // (Step 9). Avoids real Claude spawn by exercising --dry-run + auth-failure // fast-path. Full e2e is reserved for Wave 6 live-test. // // The cron writes its status file to , which // on darwin resolves to $HOME/Library/Caches/ms-ai-architect/. Setting HOME // in the subprocess env therefore points all path resolution at a tmp dir, // keeping the test isolated from the real machine. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, existsSync, readFileSync } 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 CRON = join(__dirname, '..', '..', 'scripts', 'kb-update', 'weekly-kb-cron.mjs'); function mkSandbox() { return mkdtempSync(join(tmpdir(), 'cron-test-')); } function runCron(extraArgs, env = {}) { return spawnSync('node', [CRON, ...extraArgs], { env: { PATH: process.env.PATH, ...env }, encoding: 'utf8', timeout: 30_000, }); } function statusFilePath(home) { if (osPlatform() === 'darwin') { return join(home, 'Library', 'Caches', 'ms-ai-architect', 'kb-update-status.json'); } if (osPlatform() === 'win32') { return join(home, 'AppData', 'Local', 'ms-ai-architect', 'Cache', 'kb-update-status.json'); } return join(home, '.cache', 'ms-ai-architect', 'kb-update-status.json'); } test('--dry-run exits 0 with dry-run status, no Claude spawn', () => { const home = mkSandbox(); try { const result = runCron(['--dry-run'], { HOME: home, ANTHROPIC_API_KEY: '', CLAUDE_CODE_OAUTH_TOKEN: '', }); assert.equal(result.status, 0, `stderr: ${result.stderr}\nstdout: ${result.stdout}`); assert.match(result.stdout, /DRY RUN/i); const sf = statusFilePath(home); assert.equal(existsSync(sf), true, `status file missing at ${sf}`); const status = JSON.parse(readFileSync(sf, 'utf8')); assert.equal(status.schema_version, 1); assert.equal(status.last_run_status, 'dry-run'); assert.equal(typeof status.last_run_ts, 'string'); assert.match(status.last_run_ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); assert.equal(typeof status.auth_mode, 'string'); assert.equal(typeof status.log_file, 'string'); } finally { rmSync(home, { recursive: true, force: true }); } }); test('missing auth (no --dry-run) fails fast with auth-related error', () => { const home = mkSandbox(); try { const result = runCron([], { HOME: home, ANTHROPIC_API_KEY: '', CLAUDE_CODE_OAUTH_TOKEN: '', }); assert.notEqual(result.status, 0, 'cron should exit non-zero on missing auth'); const combined = (result.stdout || '') + '\n' + (result.stderr || ''); assert.match( combined, /not safe for cron|unauthenticated|EAUTHCRON|auth/i, `expected auth error in output. stdout: ${result.stdout}\nstderr: ${result.stderr}` ); } finally { rmSync(home, { recursive: true, force: true }); } }); test('--budget-usd flag parsed and reflected in dry-run plan', () => { const home = mkSandbox(); try { const result = runCron(['--dry-run', '--budget-usd=12.50'], { HOME: home, ANTHROPIC_API_KEY: '', CLAUDE_CODE_OAUTH_TOKEN: '', }); assert.equal(result.status, 0, `stderr: ${result.stderr}`); assert.match( result.stdout, /(budget|Budget)[^\n]*12\.50|12\.5/, `expected 12.50 in dry-run output: ${result.stdout}` ); } finally { rmSync(home, { recursive: true, force: true }); } }); test('--dry-run writes status file even with no change-report present', () => { const home = mkSandbox(); try { const result = runCron(['--dry-run'], { HOME: home, ANTHROPIC_API_KEY: '', CLAUDE_CODE_OAUTH_TOKEN: '', }); assert.equal(result.status, 0); const sf = statusFilePath(home); const status = JSON.parse(readFileSync(sf, 'utf8')); // Required fields per Status File Schema (plan.md L122-153) for (const key of ['schema_version', 'last_run_status', 'last_run_ts', 'auth_mode', 'log_file', 'diagnostic']) { assert.ok(Object.prototype.hasOwnProperty.call(status, key), `missing required field: ${key}`); } } finally { rmSync(home, { recursive: true, force: true }); } });