import { test, describe, after } from 'node:test'; import assert from 'node:assert/strict'; import { defaultConfig, validateConfig, modelForRole, MEMORY_SHARDS, RALPH_EXIT_CRITERIA } from '../src/lib/config.mjs'; import { globsIntersect } from '../src/lib/lane.mjs'; import { scanSecrets } from '../src/lib/memory.mjs'; import { summarize } from '../src/lib/telemetry.mjs'; import { globMatch } from '../src/lib/repomap.mjs'; import { cleanup } from './helpers.mjs'; after(cleanup); describe('config', () => { test('default config is valid', () => { assert.deepEqual(validateConfig(defaultConfig()), []); }); test('rejects a bad isolation backend', () => { const c = defaultConfig(); c.isolation.backend = 'kubernetes'; assert.ok(validateConfig(c).some((p) => p.includes('isolation.backend'))); }); test('rejects an unknown ralph exit criterion', () => { const c = defaultConfig(); c.ralph.exitCriteria = ['vibes']; assert.ok(validateConfig(c).some((p) => p.includes('vibes'))); }); test('rejects a non-positive memory budget', () => { const c = defaultConfig(); c.memory.tokenBudget = 0; assert.ok(validateConfig(c).some((p) => p.includes('memory.tokenBudget'))); }); test('cheap roles resolve to the cheap tier model', () => { const c = defaultConfig(); const cheap = modelForRole(c, 'scout'); assert.equal(cheap, modelForRole(c, 'verifier')); assert.notEqual(cheap, modelForRole(c, 'conductor')); }); test('a role override is honoured', () => { const c = defaultConfig(); c.models.roles.scout = 'strong'; assert.equal(modelForRole(c, 'scout'), modelForRole(c, 'conductor')); }); test('exposes the five memory shards plus seed', () => { for (const s of ['failures', 'corrections', 'insights', 'conventions', 'quirks']) { assert.ok(MEMORY_SHARDS.includes(s), `missing shard ${s}`); } assert.ok(RALPH_EXIT_CRITERIA.length >= 5); }); }); describe('lane scope safety', () => { // The load-bearing invariant: two write lanes must never share a file. const overlapping = [ ['src/**', 'src/a/deep/**'], ['src/a/**', 'src/a/**'], ['**', 'anything/at/all.js'], ['src/a.js', 'src/a.js'], ['src/*.js', 'src/a.js'], ['lib/**/*.ts', 'lib/deep/nested/x.ts'], ]; for (const [a, b] of overlapping) { test(`rejects overlap: ${a} vs ${b}`, () => { assert.equal(globsIntersect(a, b), true, `${a} should intersect ${b}`); assert.equal(globsIntersect(b, a), true, 'intersection must be symmetric'); }); } const disjoint = [ ['src/a/**', 'src/b/**'], ['src/a.js', 'src/b.js'], ['docs/**', 'src/**'], ['lib/**/*.ts', 'lib/x.js'], ]; for (const [a, b] of disjoint) { test(`allows disjoint: ${a} vs ${b}`, () => { assert.equal(globsIntersect(a, b), false, `${a} should not intersect ${b}`); assert.equal(globsIntersect(b, a), false, 'disjointness must be symmetric'); }); } }); describe('glob matching', () => { test('** crosses directory boundaries', () => { assert.equal(globMatch('src/**', 'src/a/b/c.js'), true); }); test('* does not cross directory boundaries', () => { assert.equal(globMatch('src/*.js', 'src/a/b.js'), false); }); test('non-matching prefix fails', () => { assert.equal(globMatch('src/**', 'docs/a.js'), false); }); }); describe('secret scanning', () => { const secrets = [ ['github token', 'token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'], ['openai key', 'sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrst'], ['private key', '-----BEGIN RSA PRIVATE KEY-----'], ['aws key', 'AKIAIOSFODNN7EXAMPLE'], ]; for (const [name, text] of secrets) { test(`detects a ${name}`, () => { const hits = scanSecrets(text); assert.ok(hits.length > 0, `expected a hit for ${name}`); }); } test('does not fire on ordinary prose', () => { assert.deepEqual(scanSecrets('The build failed because the test timed out after 30s.'), []); }); test('never echoes the raw secret back', () => { const raw = 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; for (const hit of scanSecrets(`token ${raw}`)) { assert.ok(!JSON.stringify(hit).includes(raw), 'secret must be redacted in the finding'); } }); }); describe('telemetry summarize', () => { test('sums token usage across events', () => { const s = summarize([ { ts: '2026-01-01T00:00:00.000Z', type: 'run.start' }, { ts: '2026-01-01T00:00:10.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 100, 'gen_ai.usage.output_tokens': 50 }, { ts: '2026-01-01T00:01:00.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 200, 'gen_ai.usage.output_tokens': 25 }, ]); assert.equal(s.totals.inputTokens, 300); assert.equal(s.totals.outputTokens, 75); assert.equal(s.totals.totalTokens, 375); assert.equal(s.totals.wallMs, 60_000); }); test('tolerates empty input', () => { const s = summarize([]); assert.equal(s.totals.totalTokens, 0); assert.equal(s.totals.wallMs, 0); }); test('ignores malformed timestamps rather than producing NaN', () => { const s = summarize([ { ts: 'not-a-date', type: 'run.start' }, { ts: '2026-01-01T00:00:00.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 10 }, ]); assert.ok(Number.isFinite(s.totals.wallMs)); assert.equal(s.totals.inputTokens, 10); }); test('ignores non-numeric token fields', () => { const s = summarize([{ ts: '2026-01-01T00:00:00.000Z', 'gen_ai.usage.input_tokens': 'lots' }]); assert.equal(s.totals.inputTokens, 0); }); });