feat(ms-ai-architect): rewrite weekly-kb-cron with portable paths, auth-mode-aware pre-flight, lock+backup+rollback [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-05-05 11:10:17 +02:00
commit a0528e6ef7
2 changed files with 614 additions and 77 deletions

View file

@ -0,0 +1,126 @@
// 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 <getCacheDir('ms-ai-architect')>, 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 });
}
});