chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Unified Builder Module — All APOPHIS plugin builder functions
|
||||
*
|
||||
* Responsibility: Consolidate builder files into a single module
|
||||
* to reduce module fragmentation and improve maintainability.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { ExtensionRegistry } from '../extension/types.js'
|
||||
import type { ScenarioConfig, ScenarioResult, ScopeRegistry, ApophisOptions, TestConfig, TestSuite, CheckResult } from '../types.js'
|
||||
import type { CleanupManager, TrackedResource } from '../infrastructure/cleanup-manager.js'
|
||||
import type { PluginContractRegistry } from '../domain/plugin-contracts.js'
|
||||
import type { OutboundContractRegistry } from '../domain/outbound-contracts.js'
|
||||
import { runScenario } from '../test/scenario-runner.js'
|
||||
import { runPetitTests } from '../test/petit-runner.js'
|
||||
import { runStatefulTests } from '../test/stateful-runner.js'
|
||||
import { assertNonProduction } from '../infrastructure/production-safety.js'
|
||||
import { discoverRoutes } from '../domain/discovery.js'
|
||||
import { buildRequest, extractPathParams } from '../domain/request-builder.js'
|
||||
import { executeHttp } from '../infrastructure/http-executor.js'
|
||||
import { validatePostconditionsAsync } from '../domain/contract-validation.js'
|
||||
import { createOperationResolver, prefetchPreviousOperations } from '../formula/runtime.js'
|
||||
import { parse } from '../formula/parser.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const normalizeTestConfig = (opts: TestConfig = {}): TestConfig => ({
|
||||
depth: opts.depth ?? 'standard',
|
||||
scope: opts.scope,
|
||||
seed: opts.seed,
|
||||
timeout: opts.timeout,
|
||||
chaos: opts.chaos,
|
||||
routes: opts.routes,
|
||||
variants: opts.variants,
|
||||
outboundMocks: opts.outboundMocks,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build cleanup function for resource cleanup
|
||||
* @param cleanupManager - The cleanup manager instance
|
||||
* @returns Async function that triggers cleanup and returns results
|
||||
*/
|
||||
export const buildCleanup = (cleanupManager: CleanupManager) =>
|
||||
async (): Promise<Array<{ resource: TrackedResource; error?: string }>> => {
|
||||
return cleanupManager.cleanup()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build scenario runner function
|
||||
* @param fastify - Fastify instance for injection
|
||||
* @param scope - Scope registry for headers
|
||||
* @param extensionRegistry - Extension registry for custom operations
|
||||
* @returns Async function that runs a scenario config
|
||||
*/
|
||||
export const buildScenario = (
|
||||
fastify: FastifyInstance,
|
||||
scope: ScopeRegistry,
|
||||
extensionRegistry: ExtensionRegistry
|
||||
) => async (opts: ScenarioConfig): Promise<ScenarioResult> => {
|
||||
assertNonProduction('scenario')
|
||||
const scopeHeaders = scope.getHeaders(opts.scope ?? null)
|
||||
const injectInstance = fastify as unknown as import('../types.js').FastifyInjectInstance
|
||||
return runScenario(injectInstance, opts, scopeHeaders, extensionRegistry)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract (PETIT) builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build contract test runner (PETIT)
|
||||
* @param fastify - Fastify instance
|
||||
* @param scope - Scope registry
|
||||
* @param extensionRegistry - Extension registry
|
||||
* @param pluginContractRegistry - Plugin contract registry
|
||||
* @param outboundContractRegistry - Outbound contract registry
|
||||
* @returns Async function that runs PETIT tests
|
||||
*/
|
||||
export const buildContract = (
|
||||
fastify: FastifyInstance,
|
||||
scope: ScopeRegistry,
|
||||
extensionRegistry: ExtensionRegistry,
|
||||
pluginContractRegistry: PluginContractRegistry,
|
||||
outboundContractRegistry: OutboundContractRegistry
|
||||
) => async (opts: TestConfig = {}): Promise<TestSuite> => {
|
||||
const config = normalizeTestConfig(opts)
|
||||
const injectInstance = fastify as unknown as import('../types.js').FastifyInjectInstance
|
||||
const suite = await runPetitTests(injectInstance, config, scope, extensionRegistry, pluginContractRegistry, outboundContractRegistry)
|
||||
// Loud failure on empty discovery
|
||||
if (suite.tests.length === 0) {
|
||||
const routes = discoverRoutes(fastify as unknown as { routes?: Array<{ method: string; url: string; schema?: Record<string, unknown> } > })
|
||||
if (routes.length === 0) {
|
||||
throw new Error(
|
||||
'No routes discovered. Did you register APOPHIS before defining routes? ' +
|
||||
'APOPHIS must be registered via `await fastify.register(apophis)` before any routes are defined.'
|
||||
)
|
||||
}
|
||||
}
|
||||
return suite
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stateful builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build stateful test runner
|
||||
* @param fastify - Fastify instance
|
||||
* @param scope - Scope registry
|
||||
* @param cleanupManager - Cleanup manager
|
||||
* @param extensionRegistry - Extension registry
|
||||
* @param pluginContractRegistry - Plugin contract registry
|
||||
* @param outboundContractRegistry - Outbound contract registry
|
||||
* @returns Async function that runs stateful tests
|
||||
*/
|
||||
export const buildStateful = (
|
||||
fastify: FastifyInstance,
|
||||
scope: ScopeRegistry,
|
||||
cleanupManager: CleanupManager,
|
||||
extensionRegistry: ExtensionRegistry,
|
||||
pluginContractRegistry: PluginContractRegistry,
|
||||
outboundContractRegistry: OutboundContractRegistry
|
||||
) => async (opts: TestConfig = {}): Promise<TestSuite> => {
|
||||
const config = normalizeTestConfig(opts)
|
||||
const injectInstance = fastify as unknown as import('../types.js').FastifyInjectInstance
|
||||
return runStatefulTests(injectInstance, config, cleanupManager, scope, extensionRegistry, pluginContractRegistry, outboundContractRegistry)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build single route contract check runner
|
||||
* @param fastify - Fastify instance
|
||||
* @param scope - Scope registry
|
||||
* @param extensionRegistry - Extension registry
|
||||
* @param pluginContractRegistry - Plugin contract registry
|
||||
* @returns Async function that checks a single route
|
||||
*/
|
||||
export const buildCheck = (
|
||||
fastify: FastifyInstance,
|
||||
scope: ScopeRegistry,
|
||||
extensionRegistry: ExtensionRegistry,
|
||||
pluginContractRegistry: PluginContractRegistry
|
||||
) => async (method: string, path: string): Promise<CheckResult> => {
|
||||
const routes = discoverRoutes(fastify as unknown as { routes?: Array<{ method: string; url: string; schema?: Record<string, unknown> } > })
|
||||
const route = routes.find(r => r.method === method && r.path === path)
|
||||
if (!route) {
|
||||
throw new Error(`Route not found: ${method} ${path}`)
|
||||
}
|
||||
const scopeHeaders = scope.getHeaders(null)
|
||||
let request = buildRequest(route, {}, scopeHeaders, { resources: new Map(), counters: new Map() })
|
||||
// Run extension request build hooks
|
||||
for (const ext of extensionRegistry.extensions) {
|
||||
if (!ext.onBuildRequest) continue
|
||||
const extState = extensionRegistry.getState(ext.name) ?? {}
|
||||
const result = await ext.onBuildRequest({
|
||||
route,
|
||||
request,
|
||||
scopeHeaders,
|
||||
state: { resources: new Map(), counters: new Map() },
|
||||
extensionState: extState,
|
||||
})
|
||||
if (result !== undefined) {
|
||||
request = result
|
||||
}
|
||||
}
|
||||
const preContext = {
|
||||
request: {
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
query: request.query ?? {},
|
||||
params: extractPathParams(route.path, request.url),
|
||||
multipart: request.multipart,
|
||||
},
|
||||
response: {
|
||||
body: null,
|
||||
headers: {},
|
||||
statusCode: 0,
|
||||
},
|
||||
operationResolver: createOperationResolver(
|
||||
fastify as unknown as import('../types.js').FastifyInjectInstance,
|
||||
request.headers
|
||||
),
|
||||
} as import('../types.js').EvalContext
|
||||
const extensionHeaders = extensionRegistry.getExtensionHeaders()
|
||||
const apostlAsts = route.ensures.flatMap((formula) => {
|
||||
try {
|
||||
return [parse(formula, extensionHeaders).ast]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
await prefetchPreviousOperations(apostlAsts, preContext, route, extensionRegistry)
|
||||
const executedCtx = await executeHttp(fastify as unknown as import('../types.js').FastifyInjectInstance, route, request)
|
||||
const ctx = {
|
||||
...executedCtx,
|
||||
before: preContext,
|
||||
operationResolver: createOperationResolver(
|
||||
fastify as unknown as import('../types.js').FastifyInjectInstance,
|
||||
request.headers,
|
||||
preContext
|
||||
),
|
||||
} as import('../types.js').EvalContext
|
||||
const violations: import('../types.js').ContractViolation[] = []
|
||||
for (const ensure of route.ensures) {
|
||||
const result = await validatePostconditionsAsync([ensure], ctx, route, extensionRegistry)
|
||||
if (!result.success && result.violation) {
|
||||
violations.push(result.violation)
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: violations.length === 0,
|
||||
violations,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swagger / Spec builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register swagger plugin if not already present
|
||||
* @param fastify - Fastify instance
|
||||
* @param opts - APOPHIS options containing swagger config
|
||||
*/
|
||||
export const registerSwagger = async (fastify: FastifyInstance, opts: ApophisOptions): Promise<void> => {
|
||||
if ((fastify as unknown as Record<string, unknown>).swagger !== undefined) {
|
||||
return
|
||||
}
|
||||
const swagger = await import('@fastify/swagger')
|
||||
await fastify.register(swagger.default as unknown as Parameters<typeof fastify.register>[0], opts.swagger ?? {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OpenAPI spec enriched with route contract metadata
|
||||
* @param fastify - Fastify instance
|
||||
* @returns Function that returns the enriched spec object
|
||||
*/
|
||||
export const buildSpec = (fastify: FastifyInstance) => (): Record<string, unknown> => {
|
||||
const routes = discoverRoutes(fastify as unknown as { routes?: Array<{ method: string; url: string; schema?: Record<string, unknown> } > })
|
||||
const spec = (fastify as unknown as { swagger: () => Record<string, unknown> }).swagger()
|
||||
|
||||
return {
|
||||
...spec,
|
||||
'x-apophis-contracts': routes.map((route) => ({
|
||||
path: route.path,
|
||||
method: route.method,
|
||||
category: route.category,
|
||||
requires: route.requires,
|
||||
ensures: route.ensures,
|
||||
invariants: route.invariants,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RouteContract } from '../types.js'
|
||||
/**
|
||||
* Plugin Contract Types
|
||||
* Types for plugin contract system and composed contracts.
|
||||
*/
|
||||
export interface PluginContractSpec {
|
||||
/** Route path prefix pattern. Supports wildcards: '/api/**' matches '/api/users' */
|
||||
readonly appliesTo: string
|
||||
/** Contracts organized by hook phase */
|
||||
readonly hooks: {
|
||||
readonly [phase: string]: {
|
||||
/** Preconditions that must hold before this phase executes */
|
||||
readonly requires?: readonly string[]
|
||||
/** Postconditions that must hold after this phase executes */
|
||||
readonly ensures?: readonly string[]
|
||||
}
|
||||
}
|
||||
/** Plugin metadata for diagnostics */
|
||||
readonly meta?: {
|
||||
readonly name?: string
|
||||
readonly version?: string
|
||||
readonly description?: string
|
||||
}
|
||||
/**
|
||||
* Lazy extension references — Apophis extensions this plugin needs.
|
||||
* Extensions are resolved at test time from the extension registry.
|
||||
* If a required extension is missing, the plugin's contracts are skipped with a warning.
|
||||
*/
|
||||
readonly extensions?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly required?: boolean
|
||||
}>
|
||||
}
|
||||
export interface ComposedContract {
|
||||
readonly route: RouteContract
|
||||
phases: {
|
||||
[phase: string]: {
|
||||
requires: Array<{ formula: string; source: 'route' | `plugin:${string}` }>
|
||||
ensures: Array<{ formula: string; source: 'route' | `plugin:${string}` }>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* APOPHIS Plugin v1.0 — Fastify plugin entry point.
|
||||
* Thin wrapper: delegates all work to pure domain functions.
|
||||
* Fastify plugin API is accidental; APOPHIS logic is essential.
|
||||
*
|
||||
* Architecture: Orchestrator — imports focused builders from submodules.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { ScopeRegistry } from '../infrastructure/scope-registry.js'
|
||||
import { CleanupManager } from '../infrastructure/cleanup-manager.js'
|
||||
import { captureRoute } from '../domain/discovery.js'
|
||||
import { registerValidationHooks, storeRouteContract } from '../infrastructure/hook-validator.js'
|
||||
import { extractContract } from '../domain/contract.js'
|
||||
import { createExtensionRegistry } from '../extension/registry.js'
|
||||
import type { ApophisExtension } from '../extension/types.js'
|
||||
import { createPluginContractRegistry, BUILTIN_PLUGIN_CONTRACTS } from '../domain/plugin-contracts.js'
|
||||
import type { PluginContractRegistry } from '../domain/plugin-contracts.js'
|
||||
import { OutboundContractRegistry } from '../domain/outbound-contracts.js'
|
||||
import { createOutboundMockRuntime, type OutboundMockRuntime } from '../infrastructure/outbound-mock-runtime.js'
|
||||
import { validateProductionSafety, assertTestEnv } from '../infrastructure/production-safety.js'
|
||||
import {
|
||||
registerSwagger,
|
||||
buildSpec,
|
||||
buildScenario,
|
||||
buildCleanup,
|
||||
buildContract,
|
||||
buildStateful,
|
||||
buildCheck,
|
||||
} from './builders.js'
|
||||
import type { ApophisDecorations, ApophisOptions, OutboundCallRecord, OutboundContractSpec, TestConfig } from '../types.js'
|
||||
|
||||
export const apophisPlugin = async (fastify: FastifyInstance, opts: ApophisOptions): Promise<void> => {
|
||||
// Production safety: hard-fail if test-only options are present in production
|
||||
validateProductionSafety(opts)
|
||||
await registerSwagger(fastify, opts)
|
||||
// Initialize registries before route capture so onRoute can validate formulas
|
||||
// with any registered extension operation headers.
|
||||
const pluginContractRegistry = createPluginContractRegistry()
|
||||
const extensionRegistry = createExtensionRegistry()
|
||||
extensionRegistry.setPluginContractRegistry(pluginContractRegistry)
|
||||
// Initialize outbound contract registry
|
||||
const outboundContractRegistry = new OutboundContractRegistry()
|
||||
if (opts.outboundContracts) {
|
||||
outboundContractRegistry.registerAll(opts.outboundContracts)
|
||||
}
|
||||
// Track active outbound mock runtime for imperative E2E tests
|
||||
let activeMockRuntime: OutboundMockRuntime | undefined
|
||||
// Capture routes as they're registered via Fastify's onRoute hook
|
||||
fastify.addHook('onRoute', (routeOptions) => {
|
||||
const method = Array.isArray(routeOptions.method)
|
||||
? routeOptions.method.join(',')
|
||||
: routeOptions.method
|
||||
const schema = routeOptions.schema as Record<string, unknown> | undefined
|
||||
const prefix = (routeOptions as unknown as Record<string, unknown>).prefix as string | undefined
|
||||
const url = prefix && !routeOptions.url.startsWith(prefix)
|
||||
? `${prefix}${routeOptions.url}`
|
||||
: routeOptions.url
|
||||
captureRoute(fastify, {
|
||||
method,
|
||||
url,
|
||||
schema,
|
||||
prefix,
|
||||
})
|
||||
// Extract contract and attach to route config for runtime validation hooks
|
||||
const contract = extractContract(url, method, schema)
|
||||
if (contract.validateRuntime && (contract.requires.length > 0 || contract.ensures.length > 0)) {
|
||||
const config = routeOptions.config as Record<string, unknown> || {}
|
||||
config.apophisContract = contract
|
||||
routeOptions.config = config as typeof routeOptions.config
|
||||
// Store for hook validator lookup (Fastify doesn't expose routes after ready)
|
||||
const routeKey = `${contract.method} ${contract.path}`
|
||||
storeRouteContract(routeKey, contract, extensionRegistry.getExtensionHeaders())
|
||||
}
|
||||
})
|
||||
// Initialize scope registry with explicit config or empty
|
||||
const scope = new ScopeRegistry(opts.scopes ?? {})
|
||||
const cleanupManager = new CleanupManager(fastify, scope, opts.cleanup ?? false)
|
||||
// Register built-in plugin contracts
|
||||
for (const [name, spec] of Object.entries(BUILTIN_PLUGIN_CONTRACTS)) {
|
||||
pluginContractRegistry.register(name, spec)
|
||||
}
|
||||
// Register user-provided plugin contracts
|
||||
if (opts.pluginContracts) {
|
||||
for (const [name, spec] of Object.entries(opts.pluginContracts)) {
|
||||
pluginContractRegistry.register(name, spec)
|
||||
}
|
||||
}
|
||||
if (opts.extensions) {
|
||||
for (const ext of opts.extensions) {
|
||||
extensionRegistry.register(ext as ApophisExtension)
|
||||
}
|
||||
}
|
||||
const decorations: ApophisDecorations = {
|
||||
scope,
|
||||
contract: buildContract(fastify, scope, extensionRegistry, pluginContractRegistry, outboundContractRegistry),
|
||||
stateful: buildStateful(fastify, scope, cleanupManager, extensionRegistry, pluginContractRegistry, outboundContractRegistry),
|
||||
check: buildCheck(fastify, scope, extensionRegistry, pluginContractRegistry),
|
||||
scenario: buildScenario(fastify, scope, extensionRegistry),
|
||||
cleanup: buildCleanup(cleanupManager),
|
||||
spec: buildSpec(fastify),
|
||||
test: {
|
||||
registerPluginContracts: (name: string, spec: import('../types.js').PluginContractSpec) => {
|
||||
assertTestEnv('registerPluginContracts')
|
||||
pluginContractRegistry.register(name, spec)
|
||||
},
|
||||
registerOutboundContracts: (contracts: Record<string, OutboundContractSpec>) => {
|
||||
assertTestEnv('registerOutboundContracts')
|
||||
outboundContractRegistry.registerAll(contracts)
|
||||
},
|
||||
enableOutboundMocks: (mockOpts?: TestConfig['outboundMocks']) => {
|
||||
assertTestEnv('enableOutboundMocks')
|
||||
if (activeMockRuntime) {
|
||||
activeMockRuntime.restore()
|
||||
}
|
||||
// Disable case
|
||||
if (mockOpts === false) {
|
||||
activeMockRuntime = undefined
|
||||
return
|
||||
}
|
||||
const contracts = outboundContractRegistry.resolve(
|
||||
Array.from(outboundContractRegistry['contracts'].entries()).map(([name]) => name)
|
||||
)
|
||||
const mode = mockOpts?.mode ?? 'example'
|
||||
const unmatched = mockOpts?.unmatched ?? 'error'
|
||||
const seed = Math.floor(Math.random() * 0xFFFFFFFF)
|
||||
activeMockRuntime = createOutboundMockRuntime({
|
||||
contracts,
|
||||
mode,
|
||||
overrides: mockOpts?.overrides,
|
||||
unmatched,
|
||||
seed,
|
||||
})
|
||||
activeMockRuntime.install()
|
||||
},
|
||||
disableOutboundMocks: () => {
|
||||
assertTestEnv('disableOutboundMocks')
|
||||
if (activeMockRuntime) {
|
||||
activeMockRuntime.restore()
|
||||
activeMockRuntime = undefined
|
||||
}
|
||||
},
|
||||
getOutboundCalls: (name?: string): ReadonlyArray<OutboundCallRecord> => {
|
||||
assertTestEnv('getOutboundCalls')
|
||||
return activeMockRuntime?.getCalls(name) ?? []
|
||||
},
|
||||
},
|
||||
}
|
||||
fastify.decorate('apophis', decorations)
|
||||
// Runtime validation: never register hooks in production
|
||||
const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'
|
||||
if (opts.runtime && opts.runtime !== 'off' && !isProd) {
|
||||
registerValidationHooks(fastify, { validateRuntime: true, runtimeLevel: opts.runtime })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user