feat(ms-ai-architect): v1.12.0 manuell KB-refresh — fjern launchd/cron-arkitektur
ToS-vurdering konkluderte med at autonom cron-kjøring er unødvendig kompleks for en solo-fork-and-own-plugin. Apply-fasen krever LLM-resonnering uansett, så manuell trigger fra en aktiv Claude Code-sesjon er enklere og holder pluginen klart innenfor Anthropic Consumer Terms paragraf 3 (automated access only via API key or where explicitly permitted — Claude Code CLI er eksemptert som offisielt verktøy). Lagt til: - commands/kb-update.md — ny /architect:kb-update slash-kommando som driver poll, endringsrapport, microsoft_docs_fetch-update og commit fra sesjonen. Argumenter: --skip-discover, --priorities, --dry-run, --single-commit - Catalog-entry i playground HTML for kb-update (categori: tool, 4 input-felt) Slettet (Wave 3-5 reversert, ~1500 linjer + 7 testmoduler): - scripts/install-kb-cron.mjs (cross-OS scheduler-installer) - scripts/kb-update/weekly-kb-cron.mjs (cron-orkestrator med pre-flight, lock, backup, claude -p subprocess, post-run verify, rollback) - scripts/kb-update/templates/ (4 scheduler-templates: launchd plist, systemd service+timer, Windows ps1 + README) - scripts/kb-update/lib/auth-mode.mjs (cron-spesifikk auth validation) - scripts/kb-update/lib/lock-file.mjs (PID+mtime stale-detection) - scripts/kb-update/lib/cost-estimat.mjs (pre-flight budget-cap) - 7 testmoduler under tests/kb-update/ for slettet kode - tests/test-kb-update.sh (Bash-3.2-shim, erstattet av direkte node --test) Beholdt (utility-laget fortsatt brukbart): - run-weekly-update.mjs, report-changes.mjs, build-registry.mjs, discover-new-urls.mjs (KB change-detection-pipelinen) - lib/atomic-write, lib/backup, lib/cross-platform-paths, lib/log-rotate - 4 testmoduler (42/42 tester PASS) Endret: - hooks/scripts/session-start-context.mjs: fjern kb-update-status.json-overvaaking - tests/run-e2e.sh --kb-update kaller node --test direkte i stedet for shim - README.md, CLAUDE.md: KB-vedlikehold-seksjon rewriter for manuell modell - plugin.json: 1.11.0 -> 1.12.0 - Rot README + CLAUDE.md: ms-ai-architect-versjon bumpet Schedulering er bevisst utenfor scope og overlatt til brukeren — eventuelle forks som vil ha periodisk varsling kan sette opp egen cron / launchd / GitHub Actions som kjører rapport-fasen og varsler om aa kjore /architect:kb-update i CC-sesjon. Verifisering: - bash tests/validate-plugin.sh: 219 PASS, 0 FAIL - bash tests/run-e2e.sh --kb-update: 42/42 inner + suite PASS - bash tests/run-e2e.sh --playground: 271/271 PASS (statisk + parsers) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
97d1101e91
commit
a7a334c8d1
29 changed files with 238 additions and 2708 deletions
|
|
@ -1,181 +0,0 @@
|
|||
// tests/kb-update/test-auth-mode.test.mjs
|
||||
// Unit tests for scripts/kb-update/lib/auth-mode.mjs
|
||||
// Note: Test fixture credential values are deliberately short (<8 chars) to
|
||||
// stay below the secrets-scanner heuristic. They are stub markers, not keys.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
detectAuthMode,
|
||||
validateAuthForCron,
|
||||
readClaudeJson,
|
||||
} from '../../scripts/kb-update/lib/auth-mode.mjs';
|
||||
|
||||
function withTmp(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'auth-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeStubRunner(exitCode) {
|
||||
const calls = [];
|
||||
const runner = (cmd, args) => {
|
||||
calls.push({ cmd, args });
|
||||
return exitCode;
|
||||
};
|
||||
return { runner, calls };
|
||||
}
|
||||
|
||||
const MISSING_PATH = '/__definitely__/__not__/__a__/__path__/.claude.json';
|
||||
|
||||
test('detectAuthMode — ANTHROPIC_API_KEY set → api-key', () => {
|
||||
const { runner } = makeStubRunner(0);
|
||||
const mode = detectAuthMode({
|
||||
env: { ANTHROPIC_API_KEY: 'fake' },
|
||||
runner,
|
||||
claudeJsonPath: MISSING_PATH,
|
||||
});
|
||||
assert.equal(mode, 'api-key');
|
||||
});
|
||||
|
||||
test('detectAuthMode — empty ANTHROPIC_API_KEY is ignored', () => {
|
||||
const { runner } = makeStubRunner(1);
|
||||
const mode = detectAuthMode({
|
||||
env: { ANTHROPIC_API_KEY: ' ' },
|
||||
runner,
|
||||
claudeJsonPath: MISSING_PATH,
|
||||
});
|
||||
assert.equal(mode, 'unauthenticated');
|
||||
});
|
||||
|
||||
test('detectAuthMode — CLAUDE_CODE_OAUTH set → long-oauth', () => {
|
||||
const { runner } = makeStubRunner(0);
|
||||
const mode = detectAuthMode({
|
||||
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oat' },
|
||||
runner,
|
||||
claudeJsonPath: MISSING_PATH,
|
||||
});
|
||||
assert.equal(mode, 'long-oauth');
|
||||
});
|
||||
|
||||
test('detectAuthMode — both env vars set → api-key precedence', () => {
|
||||
const { runner } = makeStubRunner(0);
|
||||
const mode = detectAuthMode({
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: 'fake',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oat',
|
||||
},
|
||||
runner,
|
||||
claudeJsonPath: MISSING_PATH,
|
||||
});
|
||||
assert.equal(mode, 'api-key');
|
||||
});
|
||||
|
||||
test('detectAuthMode — neither env, no claude.json → unauthenticated', () => {
|
||||
const { runner, calls } = makeStubRunner(0);
|
||||
const mode = detectAuthMode({
|
||||
env: {},
|
||||
runner,
|
||||
claudeJsonPath: MISSING_PATH,
|
||||
});
|
||||
assert.equal(mode, 'unauthenticated');
|
||||
// Runner must NOT be invoked when claude.json is unreadable.
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('detectAuthMode — claude.json onboarded + runner exit 0 → subscription-browser-only', () => {
|
||||
withTmp((tmp) => {
|
||||
const path = join(tmp, '.claude.json');
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ hasCompletedOnboarding: true, userID: 'abc' }),
|
||||
'utf8'
|
||||
);
|
||||
const { runner, calls } = makeStubRunner(0);
|
||||
const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path });
|
||||
assert.equal(mode, 'subscription-browser-only');
|
||||
assert.deepEqual(calls, [{ cmd: 'claude', args: ['auth', 'status'] }]);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectAuthMode — claude.json onboarded + runner exit 1 → unauthenticated', () => {
|
||||
withTmp((tmp) => {
|
||||
const path = join(tmp, '.claude.json');
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ hasCompletedOnboarding: true }),
|
||||
'utf8'
|
||||
);
|
||||
const { runner } = makeStubRunner(1);
|
||||
const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path });
|
||||
assert.equal(mode, 'unauthenticated');
|
||||
});
|
||||
});
|
||||
|
||||
test('detectAuthMode — claude.json present but not onboarded → unauthenticated', () => {
|
||||
withTmp((tmp) => {
|
||||
const path = join(tmp, '.claude.json');
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ hasCompletedOnboarding: false }),
|
||||
'utf8'
|
||||
);
|
||||
const { runner, calls } = makeStubRunner(0);
|
||||
const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path });
|
||||
assert.equal(mode, 'unauthenticated');
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('readClaudeJson — returns parsed object on valid JSON', () => {
|
||||
withTmp((tmp) => {
|
||||
const path = join(tmp, '.claude.json');
|
||||
writeFileSync(path, '{"hasCompletedOnboarding": true, "x": 42}', 'utf8');
|
||||
const obj = readClaudeJson(path);
|
||||
assert.deepEqual(obj, { hasCompletedOnboarding: true, x: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
test('readClaudeJson — returns null on missing file', () => {
|
||||
assert.equal(readClaudeJson(MISSING_PATH), null);
|
||||
});
|
||||
|
||||
test('readClaudeJson — returns null on malformed JSON', () => {
|
||||
withTmp((tmp) => {
|
||||
const path = join(tmp, 'bad.json');
|
||||
writeFileSync(path, 'not json {', 'utf8');
|
||||
assert.equal(readClaudeJson(path), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('validateAuthForCron — api-key passes silently', () => {
|
||||
validateAuthForCron('api-key');
|
||||
});
|
||||
|
||||
test('validateAuthForCron — long-oauth passes silently', () => {
|
||||
validateAuthForCron('long-oauth');
|
||||
});
|
||||
|
||||
test('validateAuthForCron — subscription-browser-only throws EAUTHCRON', () => {
|
||||
assert.throws(
|
||||
() => validateAuthForCron('subscription-browser-only'),
|
||||
(err) =>
|
||||
err.code === 'EAUTHCRON' &&
|
||||
err.detectedMode === 'subscription-browser-only' &&
|
||||
/claude setup-token/.test(err.message) &&
|
||||
/ANTHROPIC_API_KEY/.test(err.message)
|
||||
);
|
||||
});
|
||||
|
||||
test('validateAuthForCron — unauthenticated throws EAUTHCRON', () => {
|
||||
assert.throws(
|
||||
() => validateAuthForCron('unauthenticated'),
|
||||
(err) => err.code === 'EAUTHCRON' && err.detectedMode === 'unauthenticated'
|
||||
);
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
// tests/kb-update/test-cost-estimat.test.mjs
|
||||
// Unit tests for scripts/kb-update/lib/cost-estimat.mjs
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { estimateCost } from '../../scripts/kb-update/lib/cost-estimat.mjs';
|
||||
|
||||
test('estimateCost — api-key returns numeric usd, kvote_warn unset', () => {
|
||||
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' });
|
||||
assert.equal(typeof result.usd, 'number');
|
||||
assert.equal(result.kvote_warn, false);
|
||||
assert.ok(result.usd > 0);
|
||||
});
|
||||
|
||||
test('estimateCost — api-key empty input returns 0 USD', () => {
|
||||
const result = estimateCost({}, { authMode: 'api-key' });
|
||||
assert.equal(result.usd, 0);
|
||||
assert.equal(result.kvote_warn, false);
|
||||
assert.equal(result.tokens_input, 0);
|
||||
assert.equal(result.tokens_output, 0);
|
||||
});
|
||||
|
||||
test('estimateCost — api-key tokens are integers', () => {
|
||||
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' });
|
||||
assert.equal(Number.isInteger(result.tokens_input), true);
|
||||
assert.equal(Number.isInteger(result.tokens_output), true);
|
||||
});
|
||||
|
||||
test('estimateCost — ignores medium and low (only critical+high counted)', () => {
|
||||
const a = estimateCost({ critical: 1, high: 1 }, { authMode: 'api-key' });
|
||||
const b = estimateCost({ critical: 1, high: 1, medium: 100, low: 100 }, { authMode: 'api-key' });
|
||||
assert.equal(a.usd, b.usd);
|
||||
assert.equal(a.tokens_input, b.tokens_input);
|
||||
});
|
||||
|
||||
test('estimateCost — long-oauth returns null usd, kvote_warn flag set', () => {
|
||||
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'long-oauth' });
|
||||
assert.strictEqual(result.usd, null);
|
||||
assert.strictEqual(result.kvote_warn, true);
|
||||
});
|
||||
|
||||
test('estimateCost — subscription-browser-only returns null usd, kvote_warn flag set', () => {
|
||||
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'subscription-browser-only' });
|
||||
assert.strictEqual(result.usd, null);
|
||||
assert.strictEqual(result.kvote_warn, true);
|
||||
});
|
||||
|
||||
test('estimateCost — auth-mode does not affect token math', () => {
|
||||
const apikey = estimateCost({ critical: 5, high: 10 }, { authMode: 'api-key' });
|
||||
const oauth = estimateCost({ critical: 5, high: 10 }, { authMode: 'long-oauth' });
|
||||
const sub = estimateCost({ critical: 5, high: 10 }, { authMode: 'subscription-browser-only' });
|
||||
assert.equal(apikey.tokens_input, oauth.tokens_input);
|
||||
assert.equal(apikey.tokens_input, sub.tokens_input);
|
||||
assert.equal(apikey.tokens_output, oauth.tokens_output);
|
||||
assert.equal(apikey.tokens_output, sub.tokens_output);
|
||||
});
|
||||
|
||||
test('estimateCost — unauthenticated treated as best-effort api-key', () => {
|
||||
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'unauthenticated' });
|
||||
assert.equal(typeof result.usd, 'number');
|
||||
assert.equal(result.kvote_warn, false);
|
||||
});
|
||||
|
||||
test('estimateCost — missing authMode opt treated as best-effort api-key', () => {
|
||||
const result = estimateCost({ critical: 3, high: 15 });
|
||||
assert.equal(typeof result.usd, 'number');
|
||||
assert.equal(result.kvote_warn, false);
|
||||
});
|
||||
|
||||
test('estimateCost — unknown priority keys are ignored', () => {
|
||||
const result = estimateCost({ critical: 1, high: 1, weird: 999 }, { authMode: 'api-key' });
|
||||
// Should equal {critical:1, high:1} alone
|
||||
const baseline = estimateCost({ critical: 1, high: 1 }, { authMode: 'api-key' });
|
||||
assert.equal(result.usd, baseline.usd);
|
||||
});
|
||||
|
||||
test('estimateCost — fixture {critical: 3, high: 15} produces expected order of magnitude', () => {
|
||||
// 18 files * (3000 in + 1500 out) tokens = 54k in, 27k out
|
||||
// api-key cost: 54k * $3/M + 27k * $15/M = $0.162 + $0.405 = $0.567
|
||||
const result = estimateCost({ critical: 3, high: 15 }, { authMode: 'api-key' });
|
||||
assert.ok(result.usd > 0.4 && result.usd < 0.8, `expected ~$0.567, got $${result.usd}`);
|
||||
});
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
// 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
// tests/kb-update/test-lock-file.test.mjs
|
||||
// Unit tests for scripts/kb-update/lib/lock-file.mjs
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
utimesSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
acquireLock,
|
||||
isPidAlive,
|
||||
} from '../../scripts/kb-update/lib/lock-file.mjs';
|
||||
|
||||
const DEAD_PID = 99999999; // far above typical PID_MAX; reliably non-existent
|
||||
|
||||
function withTmp(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'lf-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakeLock(path, { pid, started, host = 'test-host', ageMs = 0 }) {
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
pid,
|
||||
started: started ?? Date.now() - ageMs,
|
||||
host,
|
||||
version: 1,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
if (ageMs > 0) {
|
||||
const past = new Date(Date.now() - ageMs);
|
||||
utimesSync(path, past, past);
|
||||
}
|
||||
}
|
||||
|
||||
test('isPidAlive — current process is alive', () => {
|
||||
assert.equal(isPidAlive(process.pid), true);
|
||||
});
|
||||
|
||||
test('isPidAlive — non-existent PID is dead', () => {
|
||||
assert.equal(isPidAlive(DEAD_PID), false);
|
||||
});
|
||||
|
||||
test('isPidAlive — invalid input is dead', () => {
|
||||
assert.equal(isPidAlive(0), false);
|
||||
assert.equal(isPidAlive(-1), false);
|
||||
assert.equal(isPidAlive(NaN), false);
|
||||
assert.equal(isPidAlive(undefined), false);
|
||||
});
|
||||
|
||||
test('acquireLock — creates lock file with current PID metadata', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
const lock = acquireLock(path, { registerCleanup: false });
|
||||
try {
|
||||
assert.equal(lock.lockPath, path);
|
||||
assert.equal(existsSync(path), true);
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'));
|
||||
assert.equal(data.pid, process.pid);
|
||||
assert.equal(data.version, 1);
|
||||
assert.equal(typeof data.started, 'number');
|
||||
assert.equal(typeof data.host, 'string');
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — second call same process throws ELOCKED', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
const lock = acquireLock(path, { registerCleanup: false });
|
||||
try {
|
||||
assert.throws(
|
||||
() => acquireLock(path, { registerCleanup: false }),
|
||||
(err) => err.code === 'ELOCKED' && err.holderPid === process.pid
|
||||
);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — concurrent live holder (fixture lock-fil) throws ELOCKED', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
// Pre-write a lock as if held by another live process (we use process.pid
|
||||
// as a stand-in for "guaranteed alive" without forking).
|
||||
writeFakeLock(path, { pid: process.pid, ageMs: 0 });
|
||||
assert.throws(
|
||||
() => acquireLock(path, { registerCleanup: false }),
|
||||
(err) => err.code === 'ELOCKED'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — release deletes the lock file', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
const lock = acquireLock(path, { registerCleanup: false });
|
||||
assert.equal(existsSync(path), true);
|
||||
lock.release();
|
||||
assert.equal(existsSync(path), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — release on already-released lock is a no-op', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
const lock = acquireLock(path, { registerCleanup: false });
|
||||
lock.release();
|
||||
// Second release must not throw.
|
||||
lock.release();
|
||||
assert.equal(existsSync(path), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — stale lock with dead PID + old mtime is cleaned', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
writeFakeLock(path, { pid: DEAD_PID, ageMs: 2 * 60 * 60 * 1000 });
|
||||
const lock = acquireLock(path, { registerCleanup: false });
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'));
|
||||
assert.equal(data.pid, process.pid);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — stale lock with live PID but old mtime is also cleaned', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
// Live PID (us) but mtime older than default 1h threshold.
|
||||
writeFakeLock(path, { pid: process.pid, ageMs: 2 * 60 * 60 * 1000 });
|
||||
const lock = acquireLock(path, { registerCleanup: false });
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'));
|
||||
assert.equal(data.pid, process.pid);
|
||||
// started is rewritten to fresh wallclock
|
||||
assert.ok(Date.now() - data.started < 5000);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — fresh lock with live PID is NOT cleaned', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
writeFakeLock(path, { pid: process.pid, ageMs: 0 });
|
||||
assert.throws(
|
||||
() => acquireLock(path, { registerCleanup: false }),
|
||||
(err) => err.code === 'ELOCKED' && err.holderPid === process.pid
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquireLock — staleThresholdMs is honored', () => {
|
||||
withTmp((dir) => {
|
||||
const path = join(dir, 'test.lock');
|
||||
// 5s-old, live PID. Default 1h threshold → not stale → ELOCKED.
|
||||
writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
|
||||
assert.throws(
|
||||
() => acquireLock(path, { registerCleanup: false }),
|
||||
(err) => err.code === 'ELOCKED'
|
||||
);
|
||||
|
||||
// Same fixture but threshold 1s → stale → cleaned.
|
||||
writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
|
||||
const lock = acquireLock(path, {
|
||||
registerCleanup: false,
|
||||
staleThresholdMs: 1_000,
|
||||
});
|
||||
lock.release();
|
||||
assert.equal(existsSync(path), false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
// tests/kb-update/test-session-start-status.test.mjs
|
||||
// Verifies that hooks/scripts/session-start-context.mjs surfaces the
|
||||
// KB-update status file correctly per Status File Schema (plan.md L122-153).
|
||||
//
|
||||
// Same fixture statuses as test-weekly-kb-cron-flags.test.mjs so producer/
|
||||
// consumer divergence is caught at test time.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } 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 HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'session-start-context.mjs');
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function mkSandbox() {
|
||||
return mkdtempSync(join(tmpdir(), 'sshook-test-'));
|
||||
}
|
||||
|
||||
function cacheDirFor(home) {
|
||||
if (osPlatform() === 'darwin') {
|
||||
return join(home, 'Library', 'Caches', 'ms-ai-architect');
|
||||
}
|
||||
if (osPlatform() === 'win32') {
|
||||
return join(home, 'AppData', 'Local', 'ms-ai-architect', 'Cache');
|
||||
}
|
||||
return join(home, '.cache', 'ms-ai-architect');
|
||||
}
|
||||
|
||||
function writeStatus(home, status) {
|
||||
const dir = cacheDirFor(home);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'kb-update-status.json'), JSON.stringify(status, null, 2));
|
||||
}
|
||||
|
||||
function runHook(home, extraEnv = {}) {
|
||||
return spawnSync('node', [HOOK], {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: home,
|
||||
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
cwd: home, // not in plugin dir, so utredning/onboarding checks stay quiet
|
||||
});
|
||||
}
|
||||
|
||||
const baseStatus = {
|
||||
schema_version: 1,
|
||||
last_run_status: 'success',
|
||||
last_run_ts: '2026-05-05T10:00:00Z',
|
||||
duration_seconds: 412,
|
||||
auth_mode: 'api-key',
|
||||
log_file: '/tmp/kb-update.log',
|
||||
files_planned: 18,
|
||||
files_committed: 18,
|
||||
session_id: 'sess_demo',
|
||||
total_cost_usd: 1.42,
|
||||
tokens_input: 54000,
|
||||
tokens_output: 27000,
|
||||
max_turns_hit: false,
|
||||
diagnostic: null,
|
||||
};
|
||||
|
||||
test('failure status surfaces "KB-update: failure" line', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
writeStatus(home, { ...baseStatus, last_run_status: 'failure', diagnostic: 'No commits produced' });
|
||||
const result = runHook(home);
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
|
||||
assert.match(result.stdout, /KB-update: failure/);
|
||||
assert.match(result.stdout, /2026-05-05T10:00:00Z/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('partial status surfaces "KB-update: partial" line', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
writeStatus(home, { ...baseStatus, last_run_status: 'partial', files_committed: 7, max_turns_hit: true });
|
||||
const result = runHook(home);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /KB-update: partial/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('budget_exceeded status surfaces a line', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
writeStatus(home, { ...baseStatus, last_run_status: 'budget_exceeded', files_committed: 0 });
|
||||
const result = runHook(home);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /KB-update: budget_exceeded/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('success status does NOT surface a KB-update line', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
writeStatus(home, { ...baseStatus, last_run_status: 'success' });
|
||||
const result = runHook(home);
|
||||
assert.equal(result.status, 0);
|
||||
assert.doesNotMatch(result.stdout, /KB-update:/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('dry-run status does NOT surface a KB-update line', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
writeStatus(home, { ...baseStatus, last_run_status: 'dry-run' });
|
||||
const result = runHook(home);
|
||||
assert.equal(result.status, 0);
|
||||
assert.doesNotMatch(result.stdout, /KB-update:/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('missing status file → hook still exits 0 with no KB-update line', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
// No status file written.
|
||||
const result = runHook(home);
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
|
||||
assert.doesNotMatch(result.stdout, /KB-update:/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed status file → hook tolerates and exits 0', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
const dir = cacheDirFor(home);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'kb-update-status.json'), '{ this is: not, valid json');
|
||||
const result = runHook(home);
|
||||
assert.equal(result.status, 0);
|
||||
assert.doesNotMatch(result.stdout, /KB-update:/);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('hook completes in < 1 second on warm filesystem', () => {
|
||||
const home = mkSandbox();
|
||||
try {
|
||||
writeStatus(home, { ...baseStatus, last_run_status: 'failure' });
|
||||
// Warm-up.
|
||||
runHook(home);
|
||||
const start = Date.now();
|
||||
const result = runHook(home);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.equal(result.status, 0);
|
||||
assert.ok(elapsed < 1000, `hook took ${elapsed}ms (>1s)`);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
// tests/kb-update/test-template-generation.test.mjs
|
||||
// Structural-regex tests for scripts/kb-update/templates/* (Step 8).
|
||||
// Verifies that each template file exists, contains the documented sentinel
|
||||
// strings, and exposes the documented placeholder set. No template execution
|
||||
// or real scheduling occurs in this test — that lives in Wave 6 live-test.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TEMPLATES_DIR = join(__dirname, '..', '..', 'scripts', 'kb-update', 'templates');
|
||||
|
||||
const PLIST = join(TEMPLATES_DIR, 'com.fromaitochitta.ms-ai-architect.kb-update.plist');
|
||||
const SERVICE = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.service');
|
||||
const TIMER = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.timer');
|
||||
const PS1 = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.ps1');
|
||||
const README = join(TEMPLATES_DIR, 'README.md');
|
||||
|
||||
function readTpl(p) {
|
||||
assert.equal(existsSync(p), true, `template missing: ${p}`);
|
||||
return readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
test('plist — exists with required keys and placeholders', () => {
|
||||
const content = readTpl(PLIST);
|
||||
assert.match(content, /<key>Label<\/key>/);
|
||||
assert.match(content, /<key>StartCalendarInterval<\/key>/);
|
||||
assert.match(content, /<key>ProgramArguments<\/key>/);
|
||||
assert.match(content, /<key>StandardOutPath<\/key>/);
|
||||
assert.match(content, /<key>StandardErrorPath<\/key>/);
|
||||
assert.match(content, /<key>EnvironmentVariables<\/key>/);
|
||||
assert.match(content, /<key>RunAtLoad<\/key>\s*<false\/>/);
|
||||
assert.match(content, /\{\{NODE_BIN\}\}/);
|
||||
assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
|
||||
assert.match(content, /\{\{LOG_FILE\}\}/);
|
||||
assert.match(content, /\{\{SCHEDULE_HOUR\}\}/);
|
||||
assert.match(content, /\{\{SCHEDULE_MINUTE\}\}/);
|
||||
assert.match(content, /\{\{SCHEDULE_DAY_OF_WEEK\}\}/);
|
||||
});
|
||||
|
||||
test('systemd .timer — exists with OnCalendar and Persistent', () => {
|
||||
const content = readTpl(TIMER);
|
||||
assert.match(content, /\[Unit\]/);
|
||||
assert.match(content, /\[Timer\]/);
|
||||
assert.match(content, /\[Install\]/);
|
||||
assert.match(content, /OnCalendar=Wed/);
|
||||
assert.match(content, /Persistent=true/);
|
||||
assert.match(content, /WantedBy=timers\.target/);
|
||||
});
|
||||
|
||||
test('systemd .service — exists with [Unit], [Service] and ExecStart', () => {
|
||||
const content = readTpl(SERVICE);
|
||||
assert.match(content, /\[Unit\]/);
|
||||
assert.match(content, /\[Service\]/);
|
||||
assert.match(content, /ExecStart=/);
|
||||
assert.match(content, /\{\{NODE_BIN\}\}/);
|
||||
assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
|
||||
});
|
||||
|
||||
test('PowerShell ps1 — exists with Register-ScheduledTask and InteractiveToken', () => {
|
||||
const content = readTpl(PS1);
|
||||
assert.match(content, /Register-ScheduledTask/);
|
||||
assert.match(content, /InteractiveToken/);
|
||||
assert.match(content, /New-ScheduledTaskTrigger/);
|
||||
assert.match(content, /-Weekly/);
|
||||
assert.match(content, /-DaysOfWeek\s+Wednesday/);
|
||||
assert.match(content, /\{\{NODE_BIN\}\}/);
|
||||
assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
|
||||
});
|
||||
|
||||
test('README — exists and references each template by filename', () => {
|
||||
const content = readTpl(README);
|
||||
assert.match(content, /com\.fromaitochitta\.ms-ai-architect\.kb-update\.plist/);
|
||||
assert.match(content, /ms-ai-architect-kb-update\.service/);
|
||||
assert.match(content, /ms-ai-architect-kb-update\.timer/);
|
||||
assert.match(content, /ms-ai-architect-kb-update\.ps1/);
|
||||
});
|
||||
|
||||
test('plist + service + ps1 reference NODE_BIN and PLUGIN_ROOT', () => {
|
||||
// The .timer is a pure trigger — it activates the .service, which is
|
||||
// the only systemd unit that needs to know the binary + plugin root.
|
||||
// launchd and Windows put the command directly in the trigger spec, so
|
||||
// they need both placeholders themselves.
|
||||
for (const tpl of [PLIST, SERVICE, PS1]) {
|
||||
const content = readFileSync(tpl, 'utf8');
|
||||
assert.match(content, /\{\{NODE_BIN\}\}/, `${tpl} missing NODE_BIN placeholder`);
|
||||
assert.match(content, /\{\{PLUGIN_ROOT\}\}/, `${tpl} missing PLUGIN_ROOT placeholder`);
|
||||
}
|
||||
});
|
||||
|
||||
test('.timer is placeholder-free literal (Wed 04:23 hardcoded per plan)', () => {
|
||||
const content = readFileSync(TIMER, 'utf8');
|
||||
assert.match(content, /OnCalendar=Wed \*-\*-\* 04:23:00/);
|
||||
assert.doesNotMatch(content, /\{\{[A-Z_]+\}\}/);
|
||||
});
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
// 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -79,7 +79,14 @@ if $RUN_PLAYGROUND; then
|
|||
fi
|
||||
|
||||
if $RUN_KB_UPDATE; then
|
||||
bash "$SCRIPT_DIR/test-kb-update.sh" || FAILURES=$((FAILURES + 1))
|
||||
echo -e "${CYAN}─── KB Update utilities ───${NC}"
|
||||
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
if (cd "$PLUGIN_ROOT" && node --test tests/kb-update/*.test.mjs); then
|
||||
echo -e "${GREEN}KB Update: PASS${NC}"
|
||||
else
|
||||
echo -e "${RED}KB Update: FAIL${NC}"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
#!/bin/bash
|
||||
# test-kb-update.sh — Run KB-update node:test suite via the E2E harness.
|
||||
# Bash 3.2-compatible. Sources lib/e2e-helpers.sh, runs node --test against
|
||||
# the kb-update glob (Node 25 rejects directory-form arguments to --test),
|
||||
# and translates the result into the suite's pass/fail counters.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/lib/e2e-helpers.sh"
|
||||
|
||||
init_suite "KB Update"
|
||||
|
||||
cd "$PLUGIN_ROOT"
|
||||
|
||||
if ! compgen -G "tests/kb-update/*.test.mjs" >/dev/null 2>&1; then
|
||||
fail "No test files matched tests/kb-update/*.test.mjs"
|
||||
print_summary
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEST_LOG="$(mktemp -t kb-update-suite.XXXXXX)"
|
||||
trap 'rm -f "$TEST_LOG"' EXIT
|
||||
|
||||
NODE_EXIT=0
|
||||
node --test tests/kb-update/*.test.mjs >"$TEST_LOG" 2>&1 || NODE_EXIT=$?
|
||||
|
||||
cat "$TEST_LOG"
|
||||
|
||||
PASS_COUNT="$(awk '/^[^[:alnum:]]*pass[[:space:]]+[0-9]+/ { for (i=1;i<=NF;i++) if ($i=="pass") { print $(i+1); exit } }' "$TEST_LOG")"
|
||||
FAIL_COUNT="$(awk '/^[^[:alnum:]]*fail[[:space:]]+[0-9]+/ { for (i=1;i<=NF;i++) if ($i=="fail") { print $(i+1); exit } }' "$TEST_LOG")"
|
||||
TESTS_COUNT="$(awk '/^[^[:alnum:]]*tests[[:space:]]+[0-9]+/ { for (i=1;i<=NF;i++) if ($i=="tests") { print $(i+1); exit } }' "$TEST_LOG")"
|
||||
PASS_COUNT="${PASS_COUNT:-0}"
|
||||
FAIL_COUNT="${FAIL_COUNT:-0}"
|
||||
TESTS_COUNT="${TESTS_COUNT:-0}"
|
||||
|
||||
if [ "$NODE_EXIT" -eq 0 ] && [ "$FAIL_COUNT" -eq 0 ]; then
|
||||
pass "node --test tests/kb-update/*.test.mjs ($PASS_COUNT/$TESTS_COUNT pass)"
|
||||
else
|
||||
fail "node --test failed (pass=$PASS_COUNT, fail=$FAIL_COUNT, tests=$TESTS_COUNT, exit=$NODE_EXIT)"
|
||||
fi
|
||||
|
||||
print_summary
|
||||
|
|
@ -151,10 +151,10 @@ for f in "$PLUGIN_ROOT"/commands/*.md; do
|
|||
fail "Command-ID '${cmd_id}' mangler i v3 HTML"
|
||||
fi
|
||||
done
|
||||
if [ "$cmd_count" -eq 24 ]; then
|
||||
pass "24 command-filer funnet i commands/ (forventet 24)"
|
||||
if [ "$cmd_count" -eq 25 ]; then
|
||||
pass "25 command-filer funnet i commands/ (forventet 25)"
|
||||
else
|
||||
fail "Forventet 24 command-filer, fant $cmd_count"
|
||||
fail "Forventet 25 command-filer, fant $cmd_count"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue