import { test } from 'node:test'; import assert from 'node:assert'; import * as fc from 'fast-check'; import { validateConfigAgainstSchema, validateConfigSemantics, ConfigValidationError, CONFIG_SCHEMA, loadConfig, } from '../../cli/core/config-loader.js'; import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; function tempDir(): string { return mkdtempSync(join(tmpdir(), 'apophis-config-test-')); } function writeJson(dir: string, file: string, value: unknown): string { const filePath = join(dir, file); writeFileSync(filePath, JSON.stringify(value, null, 2)); return filePath; } function expectValidationError( fn: () => void, expectedPath: string, ): ConfigValidationError { try { fn(); assert.fail('Expected ConfigValidationError'); } catch (err) { assert.ok(err instanceof ConfigValidationError); assert.strictEqual(err.path, expectedPath); return err; } } async function expectLoadConfigError( config: Record, expectedPath: string, options: Partial[0]> = {}, ): Promise { const dir = tempDir(); writeJson(dir, 'apophis.config.json', config); try { await loadConfig({ cwd: dir, ...options }); assert.fail('Expected ConfigValidationError'); } catch (err) { assert.ok(err instanceof ConfigValidationError); assert.strictEqual(err.path, expectedPath); return err; } } test('schema: accepts minimal valid configs', () => { validateConfigAgainstSchema({}, CONFIG_SCHEMA); validateConfigAgainstSchema({ mode: 'verify', routes: ['GET /users'], seed: 42 }, CONFIG_SCHEMA); validateConfigAgainstSchema({ presets: { quick: { runs: 10, timeout: 5000 } } }, CONFIG_SCHEMA); }); test('schema: rejects unknown keys with guidance', () => { const cases = [ { value: { unknown: true }, path: 'unknown', guidance: 'Valid top-level keys', }, { value: { profiles: { default: { unknownKey: 1 } } }, path: 'profiles.default.unknownKey', guidance: 'Valid keys for profiles entries', }, { value: { presets: { quick: { unknownKey: 1 } } }, path: 'presets.quick.unknownKey', guidance: 'Valid keys for presets entries', }, { value: { environments: { ci: { unknownKey: 1 } } }, path: 'environments.ci.unknownKey', guidance: 'Valid keys for environments entries', }, ] as const; for (const c of cases) { const err = expectValidationError(() => validateConfigAgainstSchema(c.value, CONFIG_SCHEMA), c.path); assert.ok(err.message.includes('Unknown config key')); assert.ok(err.guidance?.includes(c.guidance)); } }); test('schema: rejects key type mismatches', () => { const cases = [ { value: { mode: 123 }, path: 'mode', key: 'mode', expectedType: 'string' }, { value: { seed: '42' }, path: 'seed', key: 'seed', expectedType: 'number' }, { value: { routes: 'GET /users' }, path: 'routes', key: 'routes', expectedType: 'array' }, { value: { routes: ['GET /users', 1] }, path: 'routes[1]', key: 'routes[1]', expectedType: 'string' }, { value: { presets: { quick: { parallel: 'yes' } } }, path: 'presets.quick.parallel', key: 'parallel', expectedType: 'boolean', }, { value: { environments: { ci: { allowVerify: 'yes' } } }, path: 'environments.ci.allowVerify', key: 'allowVerify', expectedType: 'boolean', }, ] as const; for (const c of cases) { const err = expectValidationError(() => validateConfigAgainstSchema(c.value, CONFIG_SCHEMA), c.path); assert.strictEqual(err.key, c.key); assert.ok(err.message.includes('Invalid type')); assert.ok(err.message.includes(`expected ${c.expectedType}`)); } }); test('schema: rejects enum and numeric range violations with clear guidance', () => { const enumCases = [ { value: { mode: 'invalid' }, path: 'mode', expectedGuidance: ['verify', 'observe', 'qualify'], }, { value: { profiles: { default: { mode: 'invalid' } } }, path: 'profiles.default.mode', expectedGuidance: ['verify', 'observe', 'qualify'], }, ] as const; for (const c of enumCases) { const err = expectValidationError(() => validateConfigAgainstSchema(c.value, CONFIG_SCHEMA), c.path); assert.ok(err.message.includes('Invalid value')); for (const token of c.expectedGuidance) { assert.ok(err.guidance?.includes(token)); } } const timeoutErr = expectValidationError( () => validateConfigAgainstSchema({ presets: { quick: { timeout: -1 } } }, CONFIG_SCHEMA), 'presets.quick.timeout', ); assert.ok(timeoutErr.message.includes('less than minimum')); assert.ok(timeoutErr.guidance?.includes('>=')); }); test('property: non-string mode always fails schema validation', () => { const invalidModeArbitrary = fc.oneof( fc.boolean(), fc.integer(), fc.double({ noNaN: true }), fc.array(fc.anything()), fc.object(), ); fc.assert( fc.property(invalidModeArbitrary, (modeValue) => { const err = expectValidationError( () => validateConfigAgainstSchema({ mode: modeValue }, CONFIG_SCHEMA), 'mode', ); assert.strictEqual(err.key, 'mode'); assert.ok(err.message.includes('expected string')); }), { seed: 1337, numRuns: 75 }, ); }); test('property: routes arrays with one non-string item fail at that index', () => { const badItemArbitrary = fc.oneof(fc.integer(), fc.boolean(), fc.object()); fc.assert( fc.property( fc.array(fc.string(), { maxLength: 4 }), badItemArbitrary, fc.array(fc.string(), { maxLength: 4 }), (prefix, badItem, suffix) => { const routes = [...prefix, badItem, ...suffix]; const expectedPath = `routes[${prefix.length}]`; const err = expectValidationError( () => validateConfigAgainstSchema({ routes }, CONFIG_SCHEMA), expectedPath, ); assert.strictEqual(err.key, `routes[${prefix.length}]`); }, ), { seed: 2026, numRuns: 75 }, ); }); test('semantic: validates cross-reference and value rules', () => { const rejectCases = [ { value: { profiles: { default: { preset: 'missing' } }, presets: { quick: {} } }, path: 'profiles.default.preset', guidance: 'Available presets', }, { value: { environments: { staging: { allowedModes: ['verify', 'hack'] } } }, path: 'environments.staging.allowedModes', guidance: 'Allowed modes', }, { value: { routes: ['GET /users', ''] }, path: 'routes[1]', guidance: 'non-empty strings' }, { value: { routes: [' '] }, path: 'routes[0]', guidance: 'non-empty strings' }, { value: { seed: 3.14 }, path: 'seed', guidance: 'integer' }, { value: { presets: { quick: { timeout: -100 } } }, path: 'presets.quick.timeout', guidance: 'non-negative' }, { value: { presets: { quick: { timeout: -100 } } }, path: 'presets.quick.timeout', guidance: 'non-negative', }, ] as const; for (const c of rejectCases) { const err = expectValidationError(() => validateConfigSemantics(c.value as any), c.path); assert.ok((err.guidance ?? '').includes(c.guidance)); } const acceptCases = [ { profiles: { default: { preset: 'quick' } }, presets: { quick: {} } }, { environments: { staging: { allowedModes: ['verify', 'observe'] } } }, { routes: ['GET /users', 'POST /items'] }, { seed: -42 }, { seed: 0 }, { presets: { quick: { timeout: 0 } } }, ]; for (const value of acceptCases) { validateConfigSemantics(value as any); } }); test('ConfigValidationError exposes user-facing diagnostics fields', () => { const err = new ConfigValidationError('Invalid value', 'mode', 'mode', 'bad', 'Must be one of: verify, observe, qualify.'); assert.ok(err instanceof Error); assert.strictEqual(err.name, 'ConfigValidationError'); assert.strictEqual(err.path, 'mode'); assert.strictEqual(err.key, 'mode'); assert.strictEqual(err.value, 'bad'); assert.ok(err.guidance?.includes('Must be one of')); }); test('loadConfig: returns empty config when nothing is discovered', async () => { const dir = tempDir(); const result = await loadConfig({ cwd: dir }); assert.deepStrictEqual(result.config, {}); assert.strictEqual(result.configPath, null); assert.strictEqual(result.profileName, null); assert.strictEqual(result.presetName, null); }); test('loadConfig: discovery order prefers apophis.config.js over json', async () => { const dir = tempDir(); writeFileSync(join(dir, 'apophis.config.js'), `module.exports = { mode: 'verify', seed: 1 };`); writeJson(dir, 'apophis.config.json', { mode: 'observe', seed: 2 }); const result = await loadConfig({ cwd: dir }); assert.strictEqual(result.config.mode, 'verify'); assert.strictEqual(result.config.seed, 1); }); test('loadConfig: uses package.json apophis config when no config file exists', async () => { const dir = tempDir(); writeJson(dir, 'package.json', { name: 'test', apophis: { mode: 'observe', seed: 99 }, }); const result = await loadConfig({ cwd: dir }); assert.strictEqual(result.config.mode, 'observe'); assert.strictEqual(result.config.seed, 99); }); test('loadConfig: explicit config path missing throws useful error', async () => { const dir = tempDir(); await assert.rejects( loadConfig({ cwd: dir, configPath: 'missing.json' }), (err: unknown) => err instanceof Error && err.message.includes('Config file not found'), ); }); test('loadConfig: preserves schema and semantic diagnostics', async () => { const schemaErr = await expectLoadConfigError({ mode: 123 }, 'mode'); assert.ok(schemaErr.message.includes('Invalid type')); const semanticErr = await expectLoadConfigError( { profiles: { default: { preset: 'missing' } }, presets: { quick: {} } }, 'profiles.default.preset', ); assert.ok((semanticErr.guidance ?? '').includes('Available presets')); }); test('loadConfig: schema validation runs before semantic validation', async () => { const err = await expectLoadConfigError( { mode: 'invalid', profiles: { default: { preset: 'missing' } } }, 'mode', ); assert.ok(err.message.includes('Invalid value')); }); test('loadConfig: resolves profile and preset and applies profile overrides', async () => { const dir = tempDir(); writeJson(dir, 'apophis.config.json', { mode: 'verify', seed: 1, profiles: { default: { preset: 'quick', seed: 42, routes: ['GET /health'], }, }, presets: { quick: { runs: 10, timeout: 5000, parallel: false, }, }, }); const result = await loadConfig({ cwd: dir, profileName: 'default' }); assert.strictEqual(result.profileName, 'default'); assert.strictEqual(result.presetName, 'quick'); assert.strictEqual(result.config.seed, 42); assert.deepStrictEqual(result.config.routes, ['GET /health']); assert.strictEqual((result.config as Record).timeout, 5000); }); test('loadConfig: unknown profile includes available profile names', async () => { const dir = tempDir(); writeJson(dir, 'apophis.config.json', { profiles: { default: {}, nightly: {}, }, }); await assert.rejects( loadConfig({ cwd: dir, profileName: 'missing' }), (err: unknown) => { if (!(err instanceof Error)) { return false; } return err.message.includes('Unknown profile') && err.message.includes('default') && err.message.includes('nightly'); }, ); }); test('loadConfig: keeps environment policy data when env is selected', async () => { const dir = tempDir(); writeJson(dir, 'apophis.config.json', { mode: 'verify', environments: { staging: { allowVerify: true, allowObserve: true, }, }, }); const result = await loadConfig({ cwd: dir, env: 'staging' }); assert.strictEqual(result.config.mode, 'verify'); assert.deepStrictEqual(result.config.environments?.staging, { allowVerify: true, allowObserve: true, }); }); test('loadConfig: monorepo detection reports true when workspaces exist', async () => { const dir = tempDir(); writeJson(dir, 'package.json', { name: 'root', workspaces: ['packages/*'], }); mkdirSync(join(dir, 'packages', 'api'), { recursive: true }); writeJson(join(dir, 'packages', 'api'), 'package.json', { name: 'api' }); const result = await loadConfig({ cwd: dir }); assert.strictEqual(result.isMonorepo, true); });