fix: harden engine, enrich failure diagnostics, close adoption gaps
- P0: CLI verify now honors test budget with seeded multi-sample - P0: Observe sampling enforced via Math.random() gate in hook-validator - P1: Remove misleading undici-mock-agent isolation option - P1: Qualify reuses shared discoverRouteDetails() with warnings - P1: Chaos/scenario config exposed via preset schema - P1: README/docs limitations updated to current state - P2: Nested response annotations prefer 2xx deterministically - P2: --changed documented as heuristic in verify.md - Add observe sink tests (sampling 0/1, sink failure non-interference) - Add verify runs regression tests (scale, determinism, variants) - Add configured-scenario qualify test (independent of OAuth fixture) - Add coverageBreakdown to qualify artifacts (per-gate route coverage) - Add production-style observe example with real sink in docs/observe.md - Add nightly/staging vs PR gating guidance to docs/qualify.md - Enrich VerifyFailure with formula-aware diagnostics: status:201 => 'HTTP 200', body field checks => actual values - Remove stale observe CLI activation message - Document outbound mocks as process-global in getting-started.md - Refresh APOPHIS_ADOPTION_AUDIT.md with current state 903 tests pass, build clean, typecheck clean.
This commit is contained in:
@@ -30,6 +30,7 @@ export interface Config {
|
||||
environments?: Record<string, EnvironmentPolicy>;
|
||||
profiles?: Record<string, ProfileDefinition>;
|
||||
presets?: Record<string, PresetDefinition>;
|
||||
scenarios?: ScenarioConfigDef[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -53,9 +54,28 @@ export interface PresetDefinition {
|
||||
routes?: string[];
|
||||
seed?: number;
|
||||
features?: string[];
|
||||
chaos?: boolean;
|
||||
chaosStrategy?: 'one' | 'all' | 'sample' | 'routes';
|
||||
chaosSampleSize?: number;
|
||||
chaosSampleRoutes?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ScenarioConfigDef {
|
||||
name: string;
|
||||
steps: Array<{
|
||||
name: string;
|
||||
request: {
|
||||
method: string;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
expect: string[];
|
||||
capture?: Record<string, string>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LoadConfigOptions {
|
||||
cwd: string;
|
||||
configPath?: string;
|
||||
@@ -111,6 +131,20 @@ const CONFIG_SCHEMA: Record<string, SchemaField> = {
|
||||
optional: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
},
|
||||
scenarios: {
|
||||
type: 'array',
|
||||
optional: true,
|
||||
items: { type: 'object' },
|
||||
},
|
||||
chaos: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Schema for EnvironmentPolicy values (inside environments.<name>)
|
||||
@@ -140,6 +174,10 @@ const PROFILE_SCHEMA: Record<string, SchemaField> = {
|
||||
sampling: { type: 'number', optional: true },
|
||||
blocking: { type: 'boolean', optional: true },
|
||||
sinks: { type: 'object', optional: true },
|
||||
chaos: { type: 'object', optional: true },
|
||||
chaosStrategy: { type: 'string', optional: true, enumValues: ['one', 'all', 'sample', 'routes'] },
|
||||
chaosSampleSize: { type: 'number', optional: true, min: 1 },
|
||||
chaosSampleRoutes: { type: 'array', optional: true, items: { type: 'string' } },
|
||||
};
|
||||
|
||||
// Schema for PresetDefinition values (inside presets.<name>)
|
||||
@@ -148,6 +186,9 @@ const PRESET_SCHEMA: Record<string, SchemaField> = {
|
||||
timeout: { type: 'number', optional: true, min: 0 },
|
||||
parallel: { type: 'boolean', optional: true },
|
||||
chaos: { type: 'boolean', optional: true },
|
||||
chaosStrategy: { type: 'string', optional: true, enumValues: ['one', 'all', 'sample', 'routes'] },
|
||||
chaosSampleSize: { type: 'number', optional: true, min: 1 },
|
||||
chaosSampleRoutes: { type: 'array', optional: true, items: { type: 'string' } },
|
||||
observe: { type: 'boolean', optional: true },
|
||||
features: { type: 'array', optional: true, items: { type: 'string' } },
|
||||
sampling: { type: 'number', optional: true },
|
||||
@@ -201,7 +242,7 @@ export function loadPackageJsonConfig(cwd: string): { config: Config | null; pat
|
||||
|
||||
/**
|
||||
* Load a config file by path.
|
||||
* Supports .js, .ts (via dynamic import, assumes tsx available), and .json.
|
||||
* Supports .js, .ts (via dynamic import when tsx is available), and .json.
|
||||
*/
|
||||
export async function loadConfigFile(configPath: string): Promise<Config> {
|
||||
if (configPath.endsWith('.json')) {
|
||||
@@ -209,6 +250,14 @@ export async function loadConfigFile(configPath: string): Promise<Config> {
|
||||
return JSON.parse(content) as Config;
|
||||
}
|
||||
|
||||
if (configPath.endsWith('.ts') && !process.execArgv.some(a => a.includes('tsx') || a.includes('ts-node'))) {
|
||||
throw new Error(
|
||||
`TypeScript config file ${configPath} requires a TS loader.\n` +
|
||||
'Convert to apophis.config.js or apophis.config.json, or run through tsx:\n' +
|
||||
' npx tsx ./node_modules/.bin/apophis verify'
|
||||
);
|
||||
}
|
||||
|
||||
// For .js and .ts, use dynamic import.
|
||||
// tsx handles .ts files in dev environments.
|
||||
const fileUrl = pathToFileURL(configPath).href;
|
||||
@@ -259,6 +308,15 @@ function isInsideDynamicContainer(path: string): boolean {
|
||||
return path.startsWith('profiles.') || path.startsWith('presets.') || path.startsWith('environments.');
|
||||
}
|
||||
|
||||
/**
|
||||
* User-managed extension keys that pass through schema validation.
|
||||
* APOPHIS will never interpret these fields; they are reserved for team metadata.
|
||||
* Keys starting with 'x-' are always allowed at any level.
|
||||
*/
|
||||
function isUserExtensionKey(key: string): boolean {
|
||||
return key.startsWith('x-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value matches the expected type for a schema field.
|
||||
* Throws ConfigValidationError on type mismatch.
|
||||
@@ -406,13 +464,14 @@ export function validateConfigAgainstSchema(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isUserExtensionKey(key)) continue
|
||||
// Unknown key inside a profile/preset/environment object
|
||||
throw new ConfigValidationError(
|
||||
`Unknown config key at ${currentPath}`,
|
||||
currentPath,
|
||||
key,
|
||||
obj[key],
|
||||
`Valid keys for ${parentContainer} entries: ${Object.keys(childSchema || {}).join(', ')}.`,
|
||||
`Valid keys for ${parentContainer} entries: ${Object.keys(childSchema || {}).join(', ')}. Use x- prefixed keys for team metadata.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -420,12 +479,13 @@ export function validateConfigAgainstSchema(
|
||||
}
|
||||
|
||||
if (!fieldSchema) {
|
||||
if (isUserExtensionKey(key)) continue
|
||||
throw new ConfigValidationError(
|
||||
`Unknown config key at ${currentPath}`,
|
||||
currentPath,
|
||||
key,
|
||||
obj[key],
|
||||
`Valid top-level keys: ${Object.keys(CONFIG_SCHEMA).join(', ')}.`,
|
||||
`Valid top-level keys: ${Object.keys(CONFIG_SCHEMA).join(', ')}. Use the metadata field or x- prefixed keys for team data.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user