Initial public release of Apophis — invariant-driven automated API testing

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
parent d278c4b105
commit 3ac1daf7e9
82 changed files with 3902 additions and 1098 deletions
+2 -2
View File
@@ -116,8 +116,8 @@ export async function checkRouteDiscovery(options: RouteCheckOptions): Promise<R
name: 'route-discovery',
status: 'fail',
message: `App file ${appFile} does not export a valid object.`,
detail: 'Ensure the app file exports a Fastify instance as default.',
remediation: 'Export your Fastify instance as default: export default app;',
detail: 'Ensure the app file exports a Fastify instance or a factory function.',
remediation: 'Export your Fastify instance: export default app; or export const createApp = () => app; or module.exports = app;',
mode: 'all',
};
}
+11 -1
View File
@@ -26,6 +26,16 @@ import { runDocsChecks } from './checks/docs.js';
import { renderJson } from '../../renderers/json.js';
// Deterministic string-to-seed hash (FNV-1a)
function hashStringToSeed(str: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return Math.abs(hash >>> 0)
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -203,7 +213,7 @@ async function runPackageChecks(
}
// 6. Determinism trust signal
const testSeed = Math.floor(Math.random() * 0x7fffffff);
const testSeed = hashStringToSeed(packageName + cwd);
checks.push({
name: 'determinism',
status: 'pass',
-4
View File
@@ -17,7 +17,6 @@ export interface ScaffoldResult {
export function safeCiScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'safe-ci',
depth: 'quick',
timeout: 5000,
parallel: false,
chaos: false,
@@ -95,7 +94,6 @@ If \`apophis verify\` says "No behavioral contracts found", it means your routes
export function platformObserveScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'platform-observe',
depth: 'standard',
timeout: 10000,
parallel: true,
chaos: false,
@@ -180,7 +178,6 @@ This project was scaffolded with \`apophis init --preset platform-observe\`.
export function llmSafeScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'llm-safe',
depth: 'quick',
timeout: 3000,
parallel: false,
chaos: false,
@@ -258,7 +255,6 @@ If \`apophis verify\` says "No behavioral contracts found", it means your routes
export function protocolLabScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'protocol-lab',
depth: 'deep',
timeout: 15000,
parallel: false,
chaos: true,
+2 -27
View File
@@ -19,7 +19,7 @@
import type { CliContext } from '../../core/context.js'
import { loadConfig } from '../../core/config-loader.js'
import { PolicyEngine, detectEnvironment } from '../../core/policy-engine.js'
import { resolveGenerationProfileOverride, GenerationProfileResolutionError } from '../../core/generation-profile.js'
import { SUCCESS, BEHAVIORAL_FAILURE, USAGE_ERROR, INTERNAL_ERROR } from '../../core/exit-codes.js'
import type { CommandResult, Artifact, FailureRecord } from '../../core/types.js'
import { classifyError, ErrorTaxonomy } from '../../core/error-taxonomy.js'
@@ -54,13 +54,6 @@ function isReplayCompatibleRoute(route: string): boolean {
return ROUTE_IDENTITY_PATTERN.test(route)
}
function coerceDepth(value: unknown): TestConfig['depth'] {
if (value === 'quick' || value === 'standard' || value === 'thorough') {
return value
}
return 'standard'
}
function coerceTimeout(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
@@ -71,7 +64,6 @@ function coerceTimeout(value: unknown): number | undefined {
export interface QualifyOptions {
profile?: string
generationProfile?: string
seed?: number
config?: string
cwd?: string
@@ -529,7 +521,6 @@ export async function qualifyCommand(
): Promise<CommandResult> {
const {
profile,
generationProfile,
seed: explicitSeed,
config: configPath,
cwd,
@@ -558,7 +549,6 @@ export async function qualifyCommand(
}
const config = loadResult.config
const resolvedGenerationProfile = resolveGenerationProfileOverride(generationProfile, config)
// 2. Run policy engine checks
const policyEngine = new PolicyEngine({
@@ -600,12 +590,9 @@ export async function qualifyCommand(
// 6. Build stateful config
const presetName = profileDef?.preset
const preset = presetName ? config.presets?.[presetName] : undefined
const presetDepth = coerceDepth((preset as { depth?: unknown } | undefined)?.depth)
const presetTimeout = coerceTimeout((preset as { timeout?: unknown } | undefined)?.timeout)
const statefulConfig: TestConfig | undefined = gates.stateful
? {
depth: presetDepth,
generationProfile: resolvedGenerationProfile,
seed,
timeout: presetTimeout,
routes: profileDef?.routes,
@@ -642,7 +629,7 @@ export async function qualifyCommand(
}
return {
exitCode: USAGE_ERROR,
message: 'No Fastify app found. Ensure app.js exports a Fastify instance.',
message: 'No Fastify app found. Ensure app.js exports a Fastify instance or a factory function.\n\nSupported patterns:\n export default app\n export const createApp = () => app\n module.exports = app',
}
}
@@ -752,12 +739,6 @@ export async function qualifyCommand(
}
}
} catch (error) {
if (error instanceof GenerationProfileResolutionError) {
return {
exitCode: USAGE_ERROR,
message: error.message,
}
}
const message = error instanceof Error ? error.message : String(error)
return {
exitCode: INTERNAL_ERROR,
@@ -780,7 +761,6 @@ export async function handleQualify(
): Promise<number> {
const options: QualifyOptions = {
profile: ctx.options.profile || undefined,
generationProfile: ctx.options.generationProfile,
seed: undefined,
config: ctx.options.config || undefined,
cwd: ctx.cwd,
@@ -798,11 +778,6 @@ export async function handleQualify(
}
}
const generationProfileIdx = args.indexOf('--generation-profile')
if (generationProfileIdx !== -1 && args[generationProfileIdx + 1]) {
options.generationProfile = args[generationProfileIdx + 1]
}
const result = await qualifyCommand(options, ctx)
const format = options.format || ctx.options.format || 'human'
const machineMode = format === 'json' || format === 'ndjson' || format === 'json-summary' || format === 'ndjson-summary'
+15 -3
View File
@@ -11,12 +11,22 @@
*/
import { runStatefulTests } from '../../../test/stateful-runner.js'
import { CleanupManager } from '../../../infrastructure/cleanup-manager.js'
import type {
TestConfig,
TestSuite,
ScopeRegistry,
} from '../../../types.js'
import type { QualifyRunnerDeps, StepTrace } from './runner.js'
const minimalScopeRegistry: ScopeRegistry = {
scopes: new Map(),
defaultScope: { headers: {} },
register() {},
deriveFromRequest() { return { headers: {} } },
getHeaders() { return {} },
}
/**
* Run stateful tests with the given config.
* Wraps the existing stateful runner.
@@ -27,13 +37,15 @@ export async function runStatefulWithTraces(
): Promise<{ result: TestSuite; traces: StepTrace[] }> {
const started = Date.now()
const cleanupManager = new CleanupManager(deps.fastify as any, minimalScopeRegistry, false)
const result = await runStatefulTests(
deps.fastify,
config,
undefined, // cleanupManager — injected if needed by caller
undefined, // scopeRegistry
cleanupManager,
minimalScopeRegistry,
deps.extensionRegistry,
undefined, // pluginContractRegistry
undefined, // pluginContractRegistry — will be passed from runner when available
undefined, // outboundContractRegistry
)
+3 -18
View File
@@ -22,7 +22,7 @@
import type { CliContext } from '../../core/context.js'
import { loadConfig, findWorkspacePackages } from '../../core/config-loader.js'
import { PolicyEngine, detectEnvironment } from '../../core/policy-engine.js'
import { resolveGenerationProfileOverride, GenerationProfileResolutionError } from '../../core/generation-profile.js'
import { SUCCESS, BEHAVIORAL_FAILURE, USAGE_ERROR, INTERNAL_ERROR } from '../../core/exit-codes.js'
import type { CommandResult, Artifact, FailureRecord, RouteResult, WorkspaceRun, WorkspaceResult } from '../../core/types.js'
import { classifyError, ErrorTaxonomy } from '../../core/error-taxonomy.js'
@@ -54,7 +54,6 @@ function isReplayCompatibleRoute(route: string): boolean {
export interface VerifyOptions {
profile?: string
generationProfile?: string
routes?: string
seed?: number
changed?: boolean
@@ -381,7 +380,6 @@ export async function verifyCommand(
): Promise<CommandResult> {
const {
profile,
generationProfile,
routes: routesFlag,
seed: explicitSeed,
changed,
@@ -412,7 +410,6 @@ export async function verifyCommand(
}
const config = loadResult.config
const resolvedGenerationProfile = resolveGenerationProfileOverride(generationProfile, config)
// 2a. Resolve profile — if explicitly requested but missing, list available ones
if (profile && !config.profiles?.[profile]) {
@@ -468,7 +465,7 @@ export async function verifyCommand(
const errorMessage = err instanceof Error ? err.message : String(err)
return {
exitCode: USAGE_ERROR,
message: `No Fastify app found. Ensure app.js exports a Fastify instance.\n\nError: ${errorMessage}\n\nNext:\n Run \`apophis init\` to scaffold a working app.js and config.`,
message: `No Fastify app found. Ensure app.js exports a Fastify instance or a factory function.\n\nSupported patterns:\n export default app\n export const createApp = () => app\n module.exports = app\n\nError: ${errorMessage}\n\nNext:\n Run \`apophis init\` to scaffold a working app.js and config.`,
}
}
@@ -476,7 +473,6 @@ export async function verifyCommand(
const runResult = await runVerify({
fastify: fastify as any,
seed,
generationProfile: resolvedGenerationProfile,
timeout: typeof config.presets?.[loadResult.presetName || '']?.timeout === 'number'
? (config.presets[loadResult.presetName || ''] as { timeout?: number }).timeout
: undefined,
@@ -552,12 +548,7 @@ export async function verifyCommand(
message: `Config validation failed: ${message}`,
}
}
if (error instanceof GenerationProfileResolutionError) {
return {
exitCode: USAGE_ERROR,
message,
}
}
return {
exitCode: INTERNAL_ERROR,
message: `Internal error in verify command: ${message}`,
@@ -579,7 +570,6 @@ export async function handleVerify(
): Promise<number> {
const options: VerifyOptions = {
profile: ctx.options.profile || undefined,
generationProfile: ctx.options.generationProfile,
routes: undefined,
seed: undefined,
changed: false,
@@ -611,11 +601,6 @@ export async function handleVerify(
options.changed = true
}
const generationProfileIdx = args.indexOf('--generation-profile')
if (generationProfileIdx !== -1 && args[generationProfileIdx + 1]) {
options.generationProfile = args[generationProfileIdx + 1]
}
const workspaceMode = args.includes('--workspace')
if (workspaceMode) {
-1
View File
@@ -53,7 +53,6 @@ export interface VerifyRunResult {
export interface VerifyRunnerDeps {
fastify: FastifyInjectInstance
seed: number
generationProfile?: 'quick' | 'standard' | 'thorough'
timeout?: number
routeFilters?: string[]
changed?: boolean