chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
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<string, unknown>,
|
||||
expectedPath: string,
|
||||
options: Partial<Parameters<typeof loadConfig>[0]> = {},
|
||||
): Promise<ConfigValidationError> {
|
||||
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: { depth: 'quick', 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'],
|
||||
},
|
||||
{
|
||||
value: { presets: { quick: { depth: 'super-deep' } } },
|
||||
path: 'presets.quick.depth',
|
||||
expectedGuidance: ['quick', 'standard', 'deep'],
|
||||
},
|
||||
] 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: { depth: 'super-deep' } } },
|
||||
path: 'presets.quick.depth',
|
||||
guidance: 'quick, standard, deep',
|
||||
},
|
||||
] 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, depth: 'standard' } } },
|
||||
];
|
||||
|
||||
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: {
|
||||
depth: 'quick',
|
||||
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<string, unknown>).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);
|
||||
});
|
||||
Reference in New Issue
Block a user