1420 lines
41 KiB
Markdown
1420 lines
41 KiB
Markdown
|
|
# APOPHIS Quality Features Plan v1.2
|
||
|
|
## Chaos, Flake Detection, and Mutation Testing
|
||
|
|
|
||
|
|
**Status**: Chaos Implemented in v1.2 | **Target**: Flake + Mutation in v1.3 | **Priority**: P0
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Executive Summary
|
||
|
|
|
||
|
|
This plan adds three first-class quality assurance features to APOPHIS:
|
||
|
|
|
||
|
|
1. **Chaos Mode** — Inject controlled failures during contract execution to validate resilience guarantees
|
||
|
|
2. **Flake Detection** — Automatically rerun failing tests with varied seeds to identify non-deterministic contracts
|
||
|
|
3. **Mutation Testing** — Introduce synthetic bugs into route handlers and verify contracts catch them
|
||
|
|
|
||
|
|
These features transform APOPHIS from a contract validator into a comprehensive API quality platform. They leverage APOPHIS's unique architecture: formal contract ASTs, seeded property-based generation, extension hooks, and programmatic route access.
|
||
|
|
|
||
|
|
**Chaos Mode is implemented in v1.2.** Flake Detection and Mutation Testing are planned for v1.3 alongside Protocol Extensions (see `docs/protocol-extensions-spec.md`).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. Design Principles
|
||
|
|
|
||
|
|
### 2.1 Environment Guardrails
|
||
|
|
**All three features run ONLY in `NODE_ENV=test`.** This is non-negotiable. Implementation:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// src/quality/env-guard.ts (NEW FILE)
|
||
|
|
export const assertTestEnv = (feature: string): void => {
|
||
|
|
if (process.env.NODE_ENV !== 'test') {
|
||
|
|
throw new Error(
|
||
|
|
`${feature} is only available in test environment. ` +
|
||
|
|
`Set NODE_ENV=test to enable quality features.`
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Cited from: `src/extension/registry.ts:26-33` — `handleHookError` pattern for fatal vs warn severity.
|
||
|
|
|
||
|
|
### 2.2 Config Flags on `contract()`
|
||
|
|
All features are opt-in via `TestConfig`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// src/types.ts:218-223 (CURRENT)
|
||
|
|
export interface TestConfig {
|
||
|
|
readonly depth?: TestDepth
|
||
|
|
readonly scope?: string
|
||
|
|
readonly seed?: number
|
||
|
|
readonly timeout?: number
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Extended to:
|
||
|
|
```typescript
|
||
|
|
// src/types.ts:218-230 (PLANNED)
|
||
|
|
export interface TestConfig {
|
||
|
|
readonly depth?: TestDepth
|
||
|
|
readonly scope?: string
|
||
|
|
readonly seed?: number
|
||
|
|
readonly timeout?: number
|
||
|
|
readonly chaos?: ChaosConfig // Phase 1
|
||
|
|
readonly mutation?: MutationConfig // Phase 3
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Flake does NOT appear in config — it is **automatic** on any test failure.
|
||
|
|
|
||
|
|
### 2.3 Red-Green-Refactor per Phase
|
||
|
|
Each phase follows strict RGR:
|
||
|
|
- **Red**: Write failing test that exercises the feature
|
||
|
|
- **Green**: Implement minimal code to pass
|
||
|
|
- **Refactor**: Extract patterns, deduplicate, add types
|
||
|
|
|
||
|
|
Parallelization strategy: Phase 1 (Chaos) and test infrastructure can be developed in parallel. Phase 2 (Flake) depends on Phase 1's runner modifications. Phase 3 (Mutation) is independent after Phase 1.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Current Architecture Analysis
|
||
|
|
|
||
|
|
### 3.1 Test Runner Entry Points
|
||
|
|
|
||
|
|
**File**: `src/plugin/index.ts:48-69`
|
||
|
|
```typescript
|
||
|
|
const buildContract = (fastify, scope, extensionRegistry) => async (opts = {}) => {
|
||
|
|
const config = {
|
||
|
|
depth: opts.depth ?? 'standard',
|
||
|
|
scope: opts.scope,
|
||
|
|
seed: opts.seed,
|
||
|
|
}
|
||
|
|
const suite = await runPetitTests(injectInstance, config, scope, extensionRegistry)
|
||
|
|
// ... empty discovery check ...
|
||
|
|
return suite
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
This is the primary entry point for all contract testing. It delegates to `runPetitTests`.
|
||
|
|
|
||
|
|
### 3.2 Core Runner Loop
|
||
|
|
|
||
|
|
**File**: `src/test/petit-runner.ts:166-360`
|
||
|
|
|
||
|
|
Key sections:
|
||
|
|
- **Line 166**: `runPetitTests()` signature — accepts `TestConfig`, `ScopeRegistry`, `ExtensionRegistry`
|
||
|
|
- **Line 176-178**: Extension suite start hooks
|
||
|
|
- **Line 248-261**: Request building with extension hooks
|
||
|
|
- **Line 263-274**: `runBeforeRequestHooks` — **Chaos injection point**
|
||
|
|
- **Line 276-278**: `executeHttp()` call — **Chaos delay/error injection point**
|
||
|
|
- **Line 282-290**: `runAfterRequestHooks`
|
||
|
|
- **Line 301-306**: `validatePostconditions()` — **Flake rerun trigger point**
|
||
|
|
- **Line 307-333**: Failure handling — **Flake auto-rerun entry**
|
||
|
|
- **Line 361-362**: Cache flush
|
||
|
|
|
||
|
|
### 3.3 HTTP Execution
|
||
|
|
|
||
|
|
**File**: `src/infrastructure/http-executor.ts:63-145`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export const executeHttp = async (
|
||
|
|
fastify: FastifyInjectInstance,
|
||
|
|
route: RouteContract,
|
||
|
|
request: RequestStructure,
|
||
|
|
previous?: EvalContext,
|
||
|
|
timeoutMs?: number
|
||
|
|
): Promise<EvalContext> => {
|
||
|
|
// Line 85: const startTime = Date.now()
|
||
|
|
// Line 86: let timedOut = false
|
||
|
|
// Line 103-116: Promise.race with timeout
|
||
|
|
// Line 117-144: Error handling (including timeout context return)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Critical for Chaos**: The timeout mechanism (lines 104-113) uses `Promise.race`. Chaos delays must be injected BEFORE this race, or they must extend the timeout window.
|
||
|
|
|
||
|
|
### 3.4 Extension Hook System
|
||
|
|
|
||
|
|
**File**: `src/extension/types.ts:145-174`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
readonly onBuildRequest?: (context: RequestBuildContext) => RequestStructure | Promise<RequestStructure | undefined> | undefined
|
||
|
|
readonly onBeforeRequest?: (context: ExecutionContext) => Promise<void>
|
||
|
|
readonly onAfterRequest?: (context: ExecutionContext) => Promise<void>
|
||
|
|
readonly onSuiteStart?: (config: TestConfig) => Promise<Record<string, unknown> | undefined> | Record<string, unknown> | undefined
|
||
|
|
readonly onSuiteEnd?: (suite: TestSuite, extensionState: Record<string, unknown>) => Promise<void>
|
||
|
|
```
|
||
|
|
|
||
|
|
**File**: `src/extension/registry.ts:268-294`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async runBuildRequestHooks(context: RequestBuildContext): Promise<RequestStructure> {
|
||
|
|
let request = context.request
|
||
|
|
for (const ext of this._buildRequestExts) {
|
||
|
|
// Line 278: const result = await withTimeout(Promise.resolve(hook(hookContext)), ...)
|
||
|
|
// Line 285: if (result !== undefined) request = result
|
||
|
|
}
|
||
|
|
return request
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.5 Result Types
|
||
|
|
|
||
|
|
**File**: `src/types.ts:254-291`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export interface TestResult {
|
||
|
|
readonly ok: boolean
|
||
|
|
readonly name: string
|
||
|
|
readonly id: number
|
||
|
|
readonly directive?: string
|
||
|
|
readonly diagnostics?: TestDiagnostics
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface TestDiagnostics {
|
||
|
|
readonly error?: string
|
||
|
|
readonly violation?: ContractViolation
|
||
|
|
readonly suggestion?: string
|
||
|
|
readonly formula?: string
|
||
|
|
readonly counterexample?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface TestSuite {
|
||
|
|
readonly tests: ReadonlyArray<TestResult>
|
||
|
|
readonly summary: TestSummary
|
||
|
|
readonly routes: ReadonlyArray<RouteDisposition>
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Phase 1: Chaos Mode
|
||
|
|
|
||
|
|
### 4.1 Overview
|
||
|
|
|
||
|
|
Chaos mode injects controlled failures during contract execution:
|
||
|
|
- **Delays**: Add latency to requests (test timeout contracts)
|
||
|
|
- **Errors**: Force specific HTTP status codes (test error-handling contracts)
|
||
|
|
- **Dropouts**: Simulate network failures (test retry/timeout contracts)
|
||
|
|
|
||
|
|
**Key insight**: Chaos is NOT an extension. It is a **runner mode** that wraps `executeHttp`. This distinction matters because:
|
||
|
|
- Extensions are user-provided; Chaos is built-in
|
||
|
|
- Extensions run in dependency order; Chaos must run at a specific point in the HTTP lifecycle
|
||
|
|
- Extensions can be disabled via health checks; Chaos is controlled purely by config
|
||
|
|
|
||
|
|
### 4.2 Config Type
|
||
|
|
|
||
|
|
**File**: `src/types.ts` (NEW SECTION after line 223)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Line 224+ (NEW)
|
||
|
|
export interface ChaosConfig {
|
||
|
|
/** Probability of injecting any chaos event (0.0 - 1.0) */
|
||
|
|
readonly probability: number
|
||
|
|
/** Delay injection: add artificial latency */
|
||
|
|
readonly delay?: {
|
||
|
|
readonly probability: number // Conditional on chaos.probability
|
||
|
|
readonly minMs: number
|
||
|
|
readonly maxMs: number
|
||
|
|
}
|
||
|
|
/** Error injection: force HTTP error responses */
|
||
|
|
readonly error?: {
|
||
|
|
readonly probability: number
|
||
|
|
readonly statusCode: number // e.g., 503
|
||
|
|
readonly body?: unknown
|
||
|
|
}
|
||
|
|
/** Dropout injection: simulate network failure */
|
||
|
|
readonly dropout?: {
|
||
|
|
readonly probability: number
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.3 Chaos Engine Implementation
|
||
|
|
|
||
|
|
**File**: `src/quality/chaos.ts` (NEW FILE)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
/**
|
||
|
|
* Chaos Engineering Engine for APOPHIS
|
||
|
|
*
|
||
|
|
* Injects controlled failures into the HTTP execution pipeline.
|
||
|
|
* Uses a seeded RNG for reproducible chaos events.
|
||
|
|
*
|
||
|
|
* Architecture: Chaos is a runner concern, not an extension.
|
||
|
|
* It wraps executeHttp at the call site in petit-runner.ts.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { ChaosConfig, EvalContext, RouteContract } from '../types.js'
|
||
|
|
import type { RequestStructure } from '../domain/request-builder.js'
|
||
|
|
import { SeededRng } from '../infrastructure/seeded-rng.js'
|
||
|
|
import { getErrorMessage } from '../infrastructure/security.js'
|
||
|
|
|
||
|
|
export interface ChaosEvent {
|
||
|
|
readonly type: 'delay' | 'error' | 'dropout'
|
||
|
|
readonly injected: boolean
|
||
|
|
readonly details?: {
|
||
|
|
readonly delayMs?: number
|
||
|
|
readonly statusCode?: number
|
||
|
|
readonly reason: string
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export class ChaosEngine {
|
||
|
|
private rng: SeededRng
|
||
|
|
private events: ChaosEvent[] = []
|
||
|
|
private config: ChaosConfig
|
||
|
|
|
||
|
|
constructor(config: ChaosConfig, seed?: number) {
|
||
|
|
this.config = config
|
||
|
|
// Derive chaos seed from test seed for reproducibility
|
||
|
|
this.rng = new SeededRng(seed !== undefined ? seed + 0xCA05 : Date.now())
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Wrap executeHttp with chaos injection.
|
||
|
|
* Returns the original EvalContext or a modified one based on chaos events.
|
||
|
|
*/
|
||
|
|
async executeWithChaos(
|
||
|
|
executeHttp: () => Promise<EvalContext>,
|
||
|
|
route: RouteContract,
|
||
|
|
request: RequestStructure
|
||
|
|
): Promise<{ ctx: EvalContext; events: ChaosEvent[] }> {
|
||
|
|
this.events = []
|
||
|
|
|
||
|
|
// Global probability gate
|
||
|
|
if (!this.shouldInject(this.config.probability)) {
|
||
|
|
const ctx = await executeHttp()
|
||
|
|
return { ctx, events: this.events }
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delay injection (before HTTP call)
|
||
|
|
if (this.config.delay && this.shouldInject(this.config.delay.probability)) {
|
||
|
|
const delayMs = this.randomDelay()
|
||
|
|
this.events.push({
|
||
|
|
type: 'delay',
|
||
|
|
injected: true,
|
||
|
|
details: { delayMs, reason: `Chaos delay: ${delayMs}ms` },
|
||
|
|
})
|
||
|
|
await this.sleep(delayMs)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Dropout injection (skip HTTP call entirely)
|
||
|
|
if (this.config.dropout && this.shouldInject(this.config.dropout.probability)) {
|
||
|
|
this.events.push({
|
||
|
|
type: 'dropout',
|
||
|
|
injected: true,
|
||
|
|
details: { reason: 'Chaos dropout: network failure simulated' },
|
||
|
|
})
|
||
|
|
return {
|
||
|
|
ctx: this.buildDropoutContext(route, request),
|
||
|
|
events: this.events,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Execute the actual HTTP call
|
||
|
|
let ctx: EvalContext
|
||
|
|
try {
|
||
|
|
ctx = await executeHttp()
|
||
|
|
} catch (err) {
|
||
|
|
// Error injection: wrap actual errors in chaos context
|
||
|
|
if (this.config.error && this.shouldInject(this.config.error.probability)) {
|
||
|
|
this.events.push({
|
||
|
|
type: 'error',
|
||
|
|
injected: true,
|
||
|
|
details: {
|
||
|
|
statusCode: this.config.error.statusCode,
|
||
|
|
reason: `Chaos error: forced ${this.config.error.statusCode}`,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
return {
|
||
|
|
ctx: this.buildErrorContext(route, request, this.config.error.statusCode, this.config.error.body),
|
||
|
|
events: this.events,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Error injection: override successful responses
|
||
|
|
if (this.config.error && this.shouldInject(this.config.error.probability)) {
|
||
|
|
this.events.push({
|
||
|
|
type: 'error',
|
||
|
|
injected: true,
|
||
|
|
details: {
|
||
|
|
statusCode: this.config.error.statusCode,
|
||
|
|
reason: `Chaos error: overridden ${ctx.response.statusCode} with ${this.config.error.statusCode}`,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
return {
|
||
|
|
ctx: this.buildErrorContext(route, request, this.config.error.statusCode, this.config.error.body, ctx),
|
||
|
|
events: this.events,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { ctx, events: this.events }
|
||
|
|
}
|
||
|
|
|
||
|
|
private shouldInject(probability: number): boolean {
|
||
|
|
return this.rng.next() < probability
|
||
|
|
}
|
||
|
|
|
||
|
|
private randomDelay(): number {
|
||
|
|
if (!this.config.delay) return 0
|
||
|
|
const min = this.config.delay.minMs
|
||
|
|
const max = this.config.delay.maxMs
|
||
|
|
return min + Math.floor(this.rng.next() * (max - min + 1))
|
||
|
|
}
|
||
|
|
|
||
|
|
private sleep(ms: number): Promise<void> {
|
||
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||
|
|
}
|
||
|
|
|
||
|
|
private buildDropoutContext(route: RouteContract, request: RequestStructure): EvalContext {
|
||
|
|
return {
|
||
|
|
request: {
|
||
|
|
body: request.body,
|
||
|
|
headers: request.headers,
|
||
|
|
query: request.query || {},
|
||
|
|
params: {},
|
||
|
|
multipart: request.multipart,
|
||
|
|
},
|
||
|
|
response: {
|
||
|
|
body: { error: 'Chaos dropout: network failure simulated' },
|
||
|
|
headers: {},
|
||
|
|
statusCode: 0,
|
||
|
|
responseTime: 0,
|
||
|
|
},
|
||
|
|
previous: undefined,
|
||
|
|
timedOut: false,
|
||
|
|
timeoutMs: undefined,
|
||
|
|
redirects: [],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private buildErrorContext(
|
||
|
|
route: RouteContract,
|
||
|
|
request: RequestStructure,
|
||
|
|
statusCode: number,
|
||
|
|
body?: unknown,
|
||
|
|
originalCtx?: EvalContext
|
||
|
|
): EvalContext {
|
||
|
|
return {
|
||
|
|
request: {
|
||
|
|
body: request.body,
|
||
|
|
headers: request.headers,
|
||
|
|
query: request.query || {},
|
||
|
|
params: originalCtx?.request.params ?? {},
|
||
|
|
multipart: request.multipart,
|
||
|
|
},
|
||
|
|
response: {
|
||
|
|
body: body ?? { error: `Chaos error: forced ${statusCode}` },
|
||
|
|
headers: originalCtx?.response.headers ?? {},
|
||
|
|
statusCode,
|
||
|
|
responseTime: originalCtx?.response.responseTime ?? 0,
|
||
|
|
},
|
||
|
|
previous: originalCtx?.previous,
|
||
|
|
timedOut: false,
|
||
|
|
timeoutMs: originalCtx?.timeoutMs,
|
||
|
|
redirects: originalCtx?.redirects ?? [],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.4 Runner Integration
|
||
|
|
|
||
|
|
**File**: `src/test/petit-runner.ts` (MODIFICATIONS)
|
||
|
|
|
||
|
|
**Line 30** (after existing imports):
|
||
|
|
```typescript
|
||
|
|
import { ChaosEngine } from '../quality/chaos.js'
|
||
|
|
import { assertTestEnv } from '../quality/env-guard.js'
|
||
|
|
```
|
||
|
|
|
||
|
|
**Line 276-278** (CURRENT):
|
||
|
|
```typescript
|
||
|
|
const timeoutMs = command.route.timeout ?? config.timeout
|
||
|
|
ctx = await executeHttp(fastify, command.route, request, previousCtx, timeoutMs)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Line 276-290** (MODIFIED):
|
||
|
|
```typescript
|
||
|
|
const timeoutMs = command.route.timeout ?? config.timeout
|
||
|
|
|
||
|
|
// Phase 1: Chaos Mode
|
||
|
|
let chaosEvents: ChaosEvent[] = []
|
||
|
|
if (config.chaos) {
|
||
|
|
assertTestEnv('Chaos mode')
|
||
|
|
const chaos = new ChaosEngine(config.chaos, config.seed)
|
||
|
|
const result = await chaos.executeWithChaos(
|
||
|
|
() => executeHttp(fastify, command.route, request, previousCtx, timeoutMs),
|
||
|
|
command.route,
|
||
|
|
request
|
||
|
|
)
|
||
|
|
ctx = result.ctx
|
||
|
|
chaosEvents = result.events
|
||
|
|
} else {
|
||
|
|
ctx = await executeHttp(fastify, command.route, request, previousCtx, timeoutMs)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Line 307-333** (failure handling, ADD chaosEvents to diagnostics):
|
||
|
|
```typescript
|
||
|
|
if (!post.success) {
|
||
|
|
const diagnostics: Record<string, unknown> = {
|
||
|
|
statusCode: ctx.response.statusCode,
|
||
|
|
error: post.error,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Phase 1: Include chaos events in failure diagnostics
|
||
|
|
if (chaosEvents.length > 0) {
|
||
|
|
diagnostics.chaosEvents = chaosEvents
|
||
|
|
}
|
||
|
|
|
||
|
|
if (post.violation) {
|
||
|
|
// ... existing violation handling ...
|
||
|
|
}
|
||
|
|
|
||
|
|
results.push({
|
||
|
|
ok: false,
|
||
|
|
name,
|
||
|
|
id: testId,
|
||
|
|
diagnostics,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.5 Plugin Integration
|
||
|
|
|
||
|
|
**File**: `src/plugin/index.ts:48-55` (MODIFIED)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const buildContract = (fastify, scope, extensionRegistry) => async (opts = {}) => {
|
||
|
|
const config = {
|
||
|
|
depth: opts.depth ?? 'standard',
|
||
|
|
scope: opts.scope,
|
||
|
|
seed: opts.seed,
|
||
|
|
chaos: opts.chaos, // Phase 1: Pass through chaos config
|
||
|
|
}
|
||
|
|
// ... rest unchanged ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.6 Test Plan
|
||
|
|
|
||
|
|
**File**: `src/test/chaos.test.ts` (NEW FILE)
|
||
|
|
|
||
|
|
Test cases (red-green-refactor):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// RED (fails without implementation)
|
||
|
|
test('chaos: delay injection adds latency', async () => {
|
||
|
|
const fastify = await withApophisApp()
|
||
|
|
// Setup route with timeout contract
|
||
|
|
// Run with chaos.delay
|
||
|
|
// Assert timeout_occurred(this) == true passes
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN (minimal implementation)
|
||
|
|
// Implement ChaosEngine.sleep and delay injection
|
||
|
|
|
||
|
|
// RED
|
||
|
|
test('chaos: error injection forces status code', async () => {
|
||
|
|
// Setup route with contract status:200
|
||
|
|
// Run with chaos.error: { statusCode: 503 }
|
||
|
|
// Assert contract fails with status 503
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement error injection in ChaosEngine
|
||
|
|
|
||
|
|
// RED
|
||
|
|
test('chaos: dropout returns status 0', async () => {
|
||
|
|
// Run with chaos.dropout
|
||
|
|
// Assert statusCode is 0
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement dropout in ChaosEngine
|
||
|
|
|
||
|
|
// RED
|
||
|
|
test('chaos: respects probability gate', async () => {
|
||
|
|
// Run 100 times with probability 0
|
||
|
|
// Assert no chaos events
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement probability gate
|
||
|
|
|
||
|
|
// REFACTOR
|
||
|
|
// Extract shared test setup, add property-based tests for probability distribution
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.7 Parallelization
|
||
|
|
|
||
|
|
Chaos Phase can be parallelized with:
|
||
|
|
- **Test infrastructure setup** (`src/test/chaos.test.ts` scaffold)
|
||
|
|
- **Documentation** (`docs/chaos.md`)
|
||
|
|
- **Type definitions** (`src/types.ts` updates)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Phase 2: Flake Detection
|
||
|
|
|
||
|
|
### 5.1 Overview
|
||
|
|
|
||
|
|
Flake detection automatically reruns failing tests with varied seeds to confirm the failure is deterministic. It also detects "shrinks that go green" — where a property test finds a counterexample but the simplified version passes.
|
||
|
|
|
||
|
|
**Key insight**: Flake is NOT a config flag. It is an **automatic behavior** triggered by:
|
||
|
|
1. Any test result with `ok: false`
|
||
|
|
2. Any contract violation
|
||
|
|
|
||
|
|
This makes it impossible to accidentally ship a flaky contract.
|
||
|
|
|
||
|
|
### 5.2 Flake Rerun Strategy
|
||
|
|
|
||
|
|
When a test fails:
|
||
|
|
1. **Immediate rerun**: Run the exact same command with the same seed
|
||
|
|
- If it passes → **FLAKE DETECTED** (likely order-dependent or time-dependent)
|
||
|
|
- If it fails → proceed to step 2
|
||
|
|
2. **Seed variation**: Run with `seed + 1`, `seed + 2`, ..., `seed + N`
|
||
|
|
- If any pass → **FLAKE DETECTED** (seed-dependent non-determinism)
|
||
|
|
- If all fail → **CONFIRMED FAILURE** (deterministic bug)
|
||
|
|
|
||
|
|
**Default**: `N = 3` reruns (4 total runs including original)
|
||
|
|
|
||
|
|
### 5.3 Flake Engine Implementation
|
||
|
|
|
||
|
|
**File**: `src/quality/flake.ts` (NEW FILE)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
/**
|
||
|
|
* Flake Detection Engine for APOPHIS
|
||
|
|
*
|
||
|
|
* Automatically reruns failing tests with varied seeds to detect
|
||
|
|
* non-deterministic contracts. Flake detection is automatic — no config required.
|
||
|
|
*
|
||
|
|
* Triggered by: any test result with ok: false
|
||
|
|
* Strategy: same-seed rerun + seed-variation runs
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { TestResult, TestConfig, EvalContext, RouteContract } from '../types.js'
|
||
|
|
import { assertTestEnv } from './env-guard.js'
|
||
|
|
|
||
|
|
export interface FlakeReport {
|
||
|
|
readonly originalResult: TestResult
|
||
|
|
readonly reruns: FlakeRerun[]
|
||
|
|
readonly isFlaky: boolean
|
||
|
|
readonly confidence: 'high' | 'medium' | 'low'
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface FlakeRerun {
|
||
|
|
readonly seed: number
|
||
|
|
readonly passed: boolean
|
||
|
|
readonly ctx?: EvalContext
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface FlakeOptions {
|
||
|
|
/** Number of additional seeds to try (default: 3) */
|
||
|
|
readonly seedVariations?: number
|
||
|
|
/** Number of same-seed reruns (default: 1) */
|
||
|
|
readonly sameSeedReruns?: number
|
||
|
|
}
|
||
|
|
|
||
|
|
const DEFAULT_OPTIONS: Required<FlakeOptions> = {
|
||
|
|
seedVariations: 3,
|
||
|
|
sameSeedReruns: 1,
|
||
|
|
}
|
||
|
|
|
||
|
|
export class FlakeDetector {
|
||
|
|
private options: Required<FlakeOptions>
|
||
|
|
|
||
|
|
constructor(options: FlakeOptions = {}) {
|
||
|
|
this.options = { ...DEFAULT_OPTIONS, ...options }
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Analyze a failing test by rerunning it.
|
||
|
|
* Returns a FlakeReport indicating whether the failure is deterministic.
|
||
|
|
*/
|
||
|
|
async detectFlake(
|
||
|
|
originalResult: TestResult,
|
||
|
|
rerunFn: (seed?: number) => Promise<{ passed: boolean; ctx?: EvalContext }>,
|
||
|
|
originalSeed?: number
|
||
|
|
): Promise<FlakeReport> {
|
||
|
|
assertTestEnv('Flake detection')
|
||
|
|
|
||
|
|
const reruns: FlakeRerun[] = []
|
||
|
|
let isFlaky = false
|
||
|
|
|
||
|
|
// Same-seed reruns
|
||
|
|
for (let i = 0; i < this.options.sameSeedReruns; i++) {
|
||
|
|
const result = await rerunFn(originalSeed)
|
||
|
|
reruns.push({ seed: originalSeed ?? 0, passed: result.passed, ctx: result.ctx })
|
||
|
|
if (result.passed) {
|
||
|
|
isFlaky = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Seed-variation reruns
|
||
|
|
const baseSeed = originalSeed ?? Date.now()
|
||
|
|
for (let i = 1; i <= this.options.seedVariations; i++) {
|
||
|
|
const variedSeed = baseSeed + i
|
||
|
|
const result = await rerunFn(variedSeed)
|
||
|
|
reruns.push({ seed: variedSeed, passed: result.passed, ctx: result.ctx })
|
||
|
|
if (result.passed) {
|
||
|
|
isFlaky = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Confidence scoring
|
||
|
|
const passCount = reruns.filter(r => r.passed).length
|
||
|
|
const confidence = passCount === 0 ? 'high' : passCount >= reruns.length / 2 ? 'low' : 'medium'
|
||
|
|
|
||
|
|
return {
|
||
|
|
originalResult,
|
||
|
|
reruns,
|
||
|
|
isFlaky,
|
||
|
|
confidence,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.4 Runner Integration
|
||
|
|
|
||
|
|
**File**: `src/test/petit-runner.ts` (MODIFICATIONS)
|
||
|
|
|
||
|
|
**Line 307-333** (failure handling, CURRENT):
|
||
|
|
```typescript
|
||
|
|
if (!post.success) {
|
||
|
|
const diagnostics: Record<string, unknown> = {
|
||
|
|
statusCode: ctx.response.statusCode,
|
||
|
|
error: post.error,
|
||
|
|
}
|
||
|
|
// ... violation handling ...
|
||
|
|
results.push({ ok: false, name, id: testId, diagnostics })
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Line 307-360** (MODIFIED with Flake):
|
||
|
|
```typescript
|
||
|
|
if (!post.success) {
|
||
|
|
// Phase 2: Flake Detection — rerun failing test
|
||
|
|
const flakeReport = await this.detectFlake(
|
||
|
|
{ ok: false, name, id: testId, diagnostics: { error: post.error } },
|
||
|
|
async (seed) => {
|
||
|
|
// Rebuild request with new seed
|
||
|
|
const rerunRequest = buildRequest(command.route, command.params, scopeHeaders, state, seed !== undefined ? new SeededRng(seed) : undefined)
|
||
|
|
const rerunCtx = await executeHttp(fastify, command.route, rerunRequest, previousCtx, timeoutMs)
|
||
|
|
const rerunPost = validatePostconditions(command.route.ensures, rerunCtx, command.route, extensionRegistry)
|
||
|
|
return { passed: rerunPost.success, ctx: rerunCtx }
|
||
|
|
},
|
||
|
|
config.seed
|
||
|
|
)
|
||
|
|
|
||
|
|
const diagnostics: Record<string, unknown> = {
|
||
|
|
statusCode: ctx.response.statusCode,
|
||
|
|
error: post.error,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Include flake report if flaky
|
||
|
|
if (flakeReport.isFlaky) {
|
||
|
|
diagnostics.flake = {
|
||
|
|
isFlaky: true,
|
||
|
|
confidence: flakeReport.confidence,
|
||
|
|
reruns: flakeReport.reruns.map(r => ({
|
||
|
|
seed: r.seed,
|
||
|
|
passed: r.passed,
|
||
|
|
statusCode: r.ctx?.response.statusCode,
|
||
|
|
})),
|
||
|
|
suggestion: 'This contract failure is non-deterministic. Check for: time-dependent values, uninitialized state, race conditions, or external service calls.',
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ... existing violation handling ...
|
||
|
|
|
||
|
|
results.push({
|
||
|
|
ok: false,
|
||
|
|
name: flakeReport.isFlaky ? `${name} [FLAKY]` : name,
|
||
|
|
id: testId,
|
||
|
|
diagnostics,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.5 Plugin Integration
|
||
|
|
|
||
|
|
No plugin changes needed. Flake is fully automatic.
|
||
|
|
|
||
|
|
### 5.6 Test Plan
|
||
|
|
|
||
|
|
**File**: `src/test/flake.test.ts` (NEW FILE)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// RED
|
||
|
|
test('flake: detects time-dependent contract', async () => {
|
||
|
|
// Route that returns { timestamp: Date.now() }
|
||
|
|
// Contract: response_body(this).timestamp > 0
|
||
|
|
// First run passes, rerun might get different timestamp
|
||
|
|
// Assert flake report shows isFlaky: true
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement basic FlakeDetector
|
||
|
|
|
||
|
|
// RED
|
||
|
|
test('flake: confirms deterministic failure', async () => {
|
||
|
|
// Route that always returns 500
|
||
|
|
// Contract: status:200
|
||
|
|
// Assert all reruns fail, isFlaky: false
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement rerun logic
|
||
|
|
|
||
|
|
// RED
|
||
|
|
test('flake: varies seed for reruns', async () => {
|
||
|
|
// Route with seeded random response
|
||
|
|
// Assert reruns use different seeds
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement seed variation
|
||
|
|
|
||
|
|
// REFACTOR
|
||
|
|
// Extract shared setup, add tests for confidence scoring
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.7 Parallelization
|
||
|
|
|
||
|
|
Flake Phase can be developed in parallel with:
|
||
|
|
- Chaos Phase (if runner modifications are coordinated)
|
||
|
|
- Mutation Phase (type definitions)
|
||
|
|
- Documentation
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Phase 3: Mutation Testing
|
||
|
|
|
||
|
|
### 6.1 Overview
|
||
|
|
|
||
|
|
Mutation testing introduces synthetic bugs into route handlers and verifies that contracts catch them. It answers: "Are my contracts strong enough to detect real bugs?"
|
||
|
|
|
||
|
|
**Key insight**: Mutation is an ASSESSMENT tool, not a test mode. It runs AFTER normal contract testing and reports a score. It requires AST parsing of route handlers.
|
||
|
|
|
||
|
|
### 6.2 Config Type
|
||
|
|
|
||
|
|
**File**: `src/types.ts` (EXTENDED from Phase 1)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export interface MutationConfig {
|
||
|
|
/** Mutators to apply */
|
||
|
|
readonly mutators?: MutationType[]
|
||
|
|
/** Max mutations per route (default: 5) */
|
||
|
|
readonly maxPerRoute?: number
|
||
|
|
/** Stop after first surviving mutation per route (default: false) */
|
||
|
|
readonly stopOnSurvivor?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
export type MutationType =
|
||
|
|
| 'status-code' // Change status(201) to status(200)
|
||
|
|
| 'operator-swap' // Change > to <, === to !==
|
||
|
|
| 'field-delete' // Remove field from return object
|
||
|
|
| 'null-response' // Return null instead of object
|
||
|
|
| 'boolean-flip' // Flip boolean values
|
||
|
|
| 'string-corrupt' // Corrupt string responses
|
||
|
|
|
||
|
|
export interface MutationReport {
|
||
|
|
readonly score: number // 0.0 - 1.0 (percentage killed)
|
||
|
|
readonly total: number
|
||
|
|
readonly killed: number
|
||
|
|
readonly survived: number
|
||
|
|
readonly mutants: MutantResult[]
|
||
|
|
readonly weakRoutes: string[] // Routes with <50% kill rate
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface MutantResult {
|
||
|
|
readonly route: string
|
||
|
|
readonly method: string
|
||
|
|
readonly type: MutationType
|
||
|
|
readonly description: string
|
||
|
|
readonly killed: boolean
|
||
|
|
readonly killedBy?: string // Formula that caught it
|
||
|
|
readonly error?: string
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.3 Mutation Engine Implementation
|
||
|
|
|
||
|
|
**File**: `src/quality/mutation.ts` (NEW FILE)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
/**
|
||
|
|
* Mutation Testing Engine for APOPHIS
|
||
|
|
*
|
||
|
|
* Introduces synthetic bugs into route handlers and verifies
|
||
|
|
* that x-ensures contracts catch them.
|
||
|
|
*
|
||
|
|
* Architecture: Mutator transforms handler AST, runner executes
|
||
|
|
* contracts against mutated handler, original is restored.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { FastifyInstance } from 'fastify'
|
||
|
|
import type {
|
||
|
|
MutationConfig,
|
||
|
|
MutationType,
|
||
|
|
MutationReport,
|
||
|
|
MutantResult,
|
||
|
|
TestConfig,
|
||
|
|
TestSuite,
|
||
|
|
RouteContract
|
||
|
|
} from '../types.js'
|
||
|
|
import { assertTestEnv } from './env-guard.js'
|
||
|
|
import { runPetitTests } from '../test/petit-runner.js'
|
||
|
|
|
||
|
|
export class MutationEngine {
|
||
|
|
private config: Required<MutationConfig>
|
||
|
|
private mutants: MutantResult[] = []
|
||
|
|
|
||
|
|
constructor(config: MutationConfig = {}) {
|
||
|
|
this.config = {
|
||
|
|
mutators: config.mutators ?? ['status-code', 'operator-swap', 'field-delete'],
|
||
|
|
maxPerRoute: config.maxPerRoute ?? 5,
|
||
|
|
stopOnSurvivor: config.stopOnSurvivor ?? false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async run(
|
||
|
|
fastify: FastifyInstance,
|
||
|
|
routes: RouteContract[],
|
||
|
|
baseConfig: TestConfig
|
||
|
|
): Promise<MutationReport> {
|
||
|
|
assertTestEnv('Mutation testing')
|
||
|
|
this.mutants = []
|
||
|
|
|
||
|
|
for (const route of routes) {
|
||
|
|
await this.mutateRoute(fastify, route, baseConfig)
|
||
|
|
}
|
||
|
|
|
||
|
|
const killed = this.mutants.filter(m => m.killed).length
|
||
|
|
const total = this.mutants.length
|
||
|
|
|
||
|
|
return {
|
||
|
|
score: total === 0 ? 1.0 : killed / total,
|
||
|
|
total,
|
||
|
|
killed,
|
||
|
|
survived: total - killed,
|
||
|
|
mutants: this.mutants,
|
||
|
|
weakRoutes: this.findWeakRoutes(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async mutateRoute(
|
||
|
|
fastify: FastifyInstance,
|
||
|
|
route: RouteContract,
|
||
|
|
baseConfig: TestConfig
|
||
|
|
): Promise<void> {
|
||
|
|
const handler = this.extractHandler(fastify, route)
|
||
|
|
if (!handler) return
|
||
|
|
|
||
|
|
let mutationCount = 0
|
||
|
|
|
||
|
|
for (const mutatorType of this.config.mutators) {
|
||
|
|
if (mutationCount >= this.config.maxPerRoute) break
|
||
|
|
|
||
|
|
const mutant = await this.applyMutation(fastify, route, handler, mutatorType, baseConfig)
|
||
|
|
if (mutant) {
|
||
|
|
this.mutants.push(mutant)
|
||
|
|
mutationCount++
|
||
|
|
|
||
|
|
if (this.config.stopOnSurvivor && !mutant.killed) {
|
||
|
|
break // Stop after first survivor for this route
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private extractHandler(fastify: FastifyInstance, route: RouteContract): Function | null {
|
||
|
|
// Access Fastify's internal route handler
|
||
|
|
// Fastify stores routes in fastify[Symbol.for('fastify.route')]
|
||
|
|
// This is implementation-dependent and may require reflection
|
||
|
|
const routes = (fastify as unknown as { [key: symbol]: unknown })[Symbol.for('fastify.routes')]
|
||
|
|
// ... handler extraction logic ...
|
||
|
|
return null // Placeholder
|
||
|
|
}
|
||
|
|
|
||
|
|
private async applyMutation(
|
||
|
|
fastify: FastifyInstance,
|
||
|
|
route: RouteContract,
|
||
|
|
originalHandler: Function,
|
||
|
|
type: MutationType,
|
||
|
|
baseConfig: TestConfig
|
||
|
|
): Promise<MutantResult | null> {
|
||
|
|
// Create mutated handler
|
||
|
|
const mutatedHandler = this.createMutant(originalHandler, type)
|
||
|
|
if (!mutatedHandler) return null
|
||
|
|
|
||
|
|
// Replace handler temporarily
|
||
|
|
this.replaceHandler(fastify, route, mutatedHandler)
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Run contract test on mutated handler
|
||
|
|
const suite = await runPetitTests(
|
||
|
|
fastify as unknown as import('../types.js').FastifyInjectInstance,
|
||
|
|
{ ...baseConfig, depth: 'quick' }, // Quick depth for speed
|
||
|
|
undefined,
|
||
|
|
undefined
|
||
|
|
)
|
||
|
|
|
||
|
|
const failed = suite.tests.some(t => !t.ok)
|
||
|
|
|
||
|
|
return {
|
||
|
|
route: route.path,
|
||
|
|
method: route.method,
|
||
|
|
type,
|
||
|
|
description: this.describeMutation(type),
|
||
|
|
killed: failed,
|
||
|
|
killedBy: failed ? this.findKillingFormula(suite) : undefined,
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
// Restore original handler
|
||
|
|
this.replaceHandler(fastify, route, originalHandler)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private createMutant(handler: Function, type: MutationType): Function | null {
|
||
|
|
// AST-based mutation using meriyah or acorn
|
||
|
|
// This is the complex part — parse handler, mutate AST, regenerate
|
||
|
|
switch (type) {
|
||
|
|
case 'status-code':
|
||
|
|
return this.mutateStatusCode(handler)
|
||
|
|
case 'operator-swap':
|
||
|
|
return this.mutateOperators(handler)
|
||
|
|
case 'field-delete':
|
||
|
|
return this.mutateFieldDelete(handler)
|
||
|
|
// ... etc
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
private mutateStatusCode(handler: Function): Function | null {
|
||
|
|
// Parse handler to string
|
||
|
|
const source = handler.toString()
|
||
|
|
// Find status(code) calls and decrement by 1
|
||
|
|
const mutated = source.replace(/status\((\d+)\)/g, (_, code) => `status(${Number(code) - 1})`)
|
||
|
|
if (mutated === source) return null
|
||
|
|
// Evaluate mutated function
|
||
|
|
return new Function('return ' + mutated)()
|
||
|
|
}
|
||
|
|
|
||
|
|
private describeMutation(type: MutationType): string {
|
||
|
|
const descriptions: Record<MutationType, string> = {
|
||
|
|
'status-code': 'Decremented HTTP status code by 1',
|
||
|
|
'operator-swap': 'Swapped comparison operator',
|
||
|
|
'field-delete': 'Removed field from response object',
|
||
|
|
'null-response': 'Replaced response with null',
|
||
|
|
'boolean-flip': 'Flipped boolean value',
|
||
|
|
'string-corrupt': 'Corrupted string response',
|
||
|
|
}
|
||
|
|
return descriptions[type]
|
||
|
|
}
|
||
|
|
|
||
|
|
private findKillingFormula(suite: TestSuite): string | undefined {
|
||
|
|
const failure = suite.tests.find(t => !t.ok && t.diagnostics?.formula)
|
||
|
|
return failure?.diagnostics?.formula
|
||
|
|
}
|
||
|
|
|
||
|
|
private findWeakRoutes(): string[] {
|
||
|
|
const routeScores = new Map<string, { killed: number; total: number }>()
|
||
|
|
|
||
|
|
for (const mutant of this.mutants) {
|
||
|
|
const key = `${mutant.method} ${mutant.route}`
|
||
|
|
const current = routeScores.get(key) ?? { killed: 0, total: 0 }
|
||
|
|
current.total++
|
||
|
|
if (mutant.killed) current.killed++
|
||
|
|
routeScores.set(key, current)
|
||
|
|
}
|
||
|
|
|
||
|
|
return Array.from(routeScores.entries())
|
||
|
|
.filter(([_, scores]) => scores.killed / scores.total < 0.5)
|
||
|
|
.map(([route]) => route)
|
||
|
|
}
|
||
|
|
|
||
|
|
private replaceHandler(fastify: FastifyInstance, route: RouteContract, handler: Function): void {
|
||
|
|
// Fastify handler replacement logic
|
||
|
|
// This requires internal Fastify APIs and is implementation-dependent
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.4 Plugin Integration
|
||
|
|
|
||
|
|
**File**: `src/plugin/index.ts` (NEW METHOD)
|
||
|
|
|
||
|
|
**Line 130+** (after existing methods):
|
||
|
|
```typescript
|
||
|
|
const buildMutate = (fastify: FastifyInstance, scope: ScopeRegistry, extensionRegistry: ExtensionRegistry) => async (opts: TestConfig & { mutation?: MutationConfig } = {}): Promise<MutationReport> => {
|
||
|
|
assertTestEnv('Mutation testing')
|
||
|
|
|
||
|
|
const config = {
|
||
|
|
depth: opts.depth ?? 'standard',
|
||
|
|
scope: opts.scope,
|
||
|
|
seed: opts.seed,
|
||
|
|
}
|
||
|
|
|
||
|
|
const routes = discoverRoutes(fastify as unknown as { routes?: Array<{ method: string; url: string; schema?: Record<string, unknown> }> })
|
||
|
|
.filter(r => r.category !== 'utility')
|
||
|
|
|
||
|
|
const engine = new MutationEngine(opts.mutation)
|
||
|
|
return engine.run(fastify, routes, config)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Line 160+** (attach to fastify.apophis):
|
||
|
|
```typescript
|
||
|
|
fastify.decorate('apophis', {
|
||
|
|
contract: buildContract(fastify, scopeRegistry, extensionRegistry),
|
||
|
|
stateful: buildStateful(fastify, scopeRegistry, cleanupManager, extensionRegistry),
|
||
|
|
check: buildCheck(fastify, scopeRegistry, extensionRegistry),
|
||
|
|
cleanup: buildCleanup(cleanupManager),
|
||
|
|
spec: buildSpec(fastify),
|
||
|
|
scope: scopeRegistry,
|
||
|
|
mutate: buildMutate(fastify, scopeRegistry, extensionRegistry), // Phase 3
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.5 Test Plan
|
||
|
|
|
||
|
|
**File**: `src/test/mutation.test.ts` (NEW FILE)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// RED
|
||
|
|
test('mutation: kills status code mutation', async () => {
|
||
|
|
// Route returns status 201
|
||
|
|
// Contract: status:201
|
||
|
|
// Mutate status to 200
|
||
|
|
// Assert mutation is killed (contract fails)
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement basic status-code mutator
|
||
|
|
|
||
|
|
// RED
|
||
|
|
test('mutation: survives weak contract', async () => {
|
||
|
|
// Route returns { id: '123' }
|
||
|
|
// Contract: status:201 (doesn't check body)
|
||
|
|
// Mutate to return { id: null }
|
||
|
|
// Assert mutation survives (contract passes)
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement field-delete mutator
|
||
|
|
|
||
|
|
// RED
|
||
|
|
test('mutation: reports weak routes', async () => {
|
||
|
|
// Route with no body checks
|
||
|
|
// Assert weakRoutes includes the route
|
||
|
|
})
|
||
|
|
|
||
|
|
// GREEN
|
||
|
|
// Implement weak route detection
|
||
|
|
|
||
|
|
// REFACTOR
|
||
|
|
// Extract AST parsing utilities, add operator-swap mutator
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.6 AST Parsing Strategy
|
||
|
|
|
||
|
|
Mutation requires parsing route handler source code. Options:
|
||
|
|
|
||
|
|
1. **meriyah** (recommended): ES2020 parser, lightweight, no dependencies
|
||
|
|
2. **acorn**: Mature, plugin ecosystem
|
||
|
|
3. **Babel**: Heavy but powerful
|
||
|
|
|
||
|
|
**Implementation** (`src/quality/ast-mutator.ts`):
|
||
|
|
```typescript
|
||
|
|
import { parseScript } from 'meriyah'
|
||
|
|
import { generate } from 'astring'
|
||
|
|
|
||
|
|
export class AstMutator {
|
||
|
|
mutate(source: string, type: MutationType): string | null {
|
||
|
|
const ast = parseScript(source, { module: true })
|
||
|
|
// Walk AST, apply mutation
|
||
|
|
// Return regenerated source
|
||
|
|
return generate(ast)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6.7 Parallelization
|
||
|
|
|
||
|
|
Mutation Phase can be parallelized with:
|
||
|
|
- Documentation
|
||
|
|
- AST parser selection and setup
|
||
|
|
- CLI reporter design
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. File Structure
|
||
|
|
|
||
|
|
```
|
||
|
|
src/
|
||
|
|
quality/ # NEW DIRECTORY
|
||
|
|
env-guard.ts # Environment assertions
|
||
|
|
chaos.ts # Phase 1: ChaosEngine
|
||
|
|
flake.ts # Phase 2: FlakeDetector
|
||
|
|
mutation.ts # Phase 3: MutationEngine
|
||
|
|
ast-mutator.ts # Phase 3: AST parsing
|
||
|
|
index.ts # Public exports
|
||
|
|
test/
|
||
|
|
chaos.test.ts # Phase 1 tests
|
||
|
|
flake.test.ts # Phase 2 tests
|
||
|
|
mutation.test.ts # Phase 3 tests
|
||
|
|
helpers.ts # Shared test utilities (exists)
|
||
|
|
types.ts # Extended TestConfig
|
||
|
|
plugin/index.ts # New .mutate() method
|
||
|
|
test/petit-runner.ts # Chaos + Flake integration
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. Test Strategy
|
||
|
|
|
||
|
|
### 8.1 Unit Tests (per module)
|
||
|
|
|
||
|
|
**Chaos Engine** (`src/test/chaos.test.ts`):
|
||
|
|
- Probability gate (deterministic with seed)
|
||
|
|
- Delay injection timing
|
||
|
|
- Error injection overrides
|
||
|
|
- Dropout context structure
|
||
|
|
- Event recording
|
||
|
|
|
||
|
|
**Flake Detector** (`src/test/flake.test.ts`):
|
||
|
|
- Same-seed rerun detection
|
||
|
|
- Seed variation logic
|
||
|
|
- Confidence scoring
|
||
|
|
- Integration with runner diagnostics
|
||
|
|
|
||
|
|
**Mutation Engine** (`src/test/mutation.test.ts`):
|
||
|
|
- Status code mutation
|
||
|
|
- Operator swap mutation
|
||
|
|
- Field deletion mutation
|
||
|
|
- Handler restoration
|
||
|
|
- Score calculation
|
||
|
|
|
||
|
|
### 8.2 Integration Tests
|
||
|
|
|
||
|
|
**File**: `src/test/quality-integration.test.ts` (NEW)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test('quality: chaos + flake combo', async () => {
|
||
|
|
// Run contract with chaos
|
||
|
|
// Failing test triggers flake detection
|
||
|
|
// Assert flake report includes chaos events
|
||
|
|
})
|
||
|
|
|
||
|
|
test('quality: mutation score reflects contract strength', async () => {
|
||
|
|
// Route with strong contracts (checks body + status)
|
||
|
|
// Run mutation
|
||
|
|
// Assert score > 0.8
|
||
|
|
|
||
|
|
// Route with weak contracts (status only)
|
||
|
|
// Run mutation
|
||
|
|
// Assert score < 0.5
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### 8.3 Regression Tests
|
||
|
|
|
||
|
|
All existing 482 tests must pass unchanged. Quality features are:
|
||
|
|
- Opt-in via config (Chaos, Mutation)
|
||
|
|
- Automatic in test env only (Flake)
|
||
|
|
- Backward compatible: `TestConfig` gains optional fields
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 9. Implementation Schedule
|
||
|
|
|
||
|
|
### v1.2: Chaos (Phase 1) — COMPLETED
|
||
|
|
|
||
|
|
- `src/types.ts`: Add `ChaosConfig` ✅
|
||
|
|
- `src/quality/env-guard.ts`: Environment assertions ✅
|
||
|
|
- `src/quality/chaos.ts`: `ChaosEngine` class ✅
|
||
|
|
- `src/quality/corruption.ts`: Content-type aware corruption ✅
|
||
|
|
- `src/test/petit-runner.ts`: Wrap `executeHttp` with Chaos ✅
|
||
|
|
- `src/plugin/index.ts`: Pass `chaos` config through ✅
|
||
|
|
- `src/test/chaos.test.ts`: 21 tests ✅
|
||
|
|
- Documentation: `docs/chaos.md` ✅
|
||
|
|
|
||
|
|
### v1.3: Flake (Phase 2) — PLANNED
|
||
|
|
|
||
|
|
**Day 1-2: Core**
|
||
|
|
- `src/quality/flake.ts`: `FlakeDetector` class (2 hours)
|
||
|
|
|
||
|
|
**Day 3: Runner Integration**
|
||
|
|
- `src/test/petit-runner.ts`: Auto-rerun on failure (1.5 hours)
|
||
|
|
|
||
|
|
**Day 4: Tests**
|
||
|
|
- `src/test/flake.test.ts`: Full test suite (2 hours)
|
||
|
|
|
||
|
|
**Day 5: Refactor**
|
||
|
|
- Performance optimization (parallel reruns)
|
||
|
|
- Documentation: `docs/flake.md`
|
||
|
|
|
||
|
|
### v1.3: Mutation (Phase 3) — PLANNED
|
||
|
|
|
||
|
|
**Day 1-2: AST Infrastructure**
|
||
|
|
- `src/quality/ast-mutator.ts`: Parser + mutators (3 hours)
|
||
|
|
- `src/quality/mutation.ts`: `MutationEngine` (2 hours)
|
||
|
|
|
||
|
|
**Day 3: Plugin Integration**
|
||
|
|
- `src/plugin/index.ts`: `.mutate()` method (1 hour)
|
||
|
|
|
||
|
|
**Day 4: Tests**
|
||
|
|
- `src/test/mutation.test.ts`: Full test suite (3 hours)
|
||
|
|
|
||
|
|
**Day 5: Integration + Polish**
|
||
|
|
- `src/test/quality-integration.test.ts` (1 hour)
|
||
|
|
- Documentation: `docs/mutation.md`
|
||
|
|
|
||
|
|
### Parallel Workstreams
|
||
|
|
|
||
|
|
- **Protocol Extensions**: JWT, Time Control, Stateful predicates (see `docs/protocol-extensions-spec.md`)
|
||
|
|
- **Documentation**: Can be written alongside implementation
|
||
|
|
- **CLI reporter**: Enhance error output for quality features
|
||
|
|
- **Performance**: Benchmark mutation runs (target: <5s per route)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 10. Risk Analysis
|
||
|
|
|
||
|
|
### 10.1 Technical Risks
|
||
|
|
|
||
|
|
| Risk | Probability | Impact | Mitigation |
|
||
|
|
|------|------------|--------|------------|
|
||
|
|
| AST parsing breaks on complex handlers | Medium | High | Start with simple mutators, fallback to regex |
|
||
|
|
| Fastify internal API changes | Low | High | Use public APIs where possible, version pin |
|
||
|
|
| Flake reruns slow CI significantly | Medium | Medium | Make reruns configurable, default to 1 |
|
||
|
|
| Chaos delays cause false timeouts | Medium | Medium | Adjust timeout calculation for delays |
|
||
|
|
|
||
|
|
### 10.2 Design Decisions
|
||
|
|
|
||
|
|
**Decision**: Chaos as runner mode vs extension
|
||
|
|
- **Chosen**: Runner mode (direct `executeHttp` wrapper)
|
||
|
|
- **Rationale**: More control over injection timing, no dependency on extension health checks
|
||
|
|
|
||
|
|
**Decision**: Flake auto-run vs config flag
|
||
|
|
- **Chosen**: Auto-run on failures
|
||
|
|
- **Rationale**: Zero-config, prevents shipping flaky contracts
|
||
|
|
|
||
|
|
**Decision**: Mutation as separate method vs config flag
|
||
|
|
- **Chosen**: Separate `fastify.apophis.mutate()` method
|
||
|
|
- **Rationale**: Different mental model (assessment vs testing), different result type
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 11. Acceptance Criteria
|
||
|
|
|
||
|
|
### 11.1 Chaos
|
||
|
|
- [ ] `contract({ chaos: { probability: 0.1, delay: { minMs: 100, maxMs: 500 } } })` runs successfully
|
||
|
|
- [ ] Chaos events appear in test diagnostics
|
||
|
|
- [ ] Delays, errors, and dropouts all inject correctly
|
||
|
|
- [ ] Seeded RNG makes chaos reproducible
|
||
|
|
- [ ] Only runs in `NODE_ENV=test`
|
||
|
|
|
||
|
|
### 11.2 Flake
|
||
|
|
- [ ] Failing tests automatically rerun with same seed
|
||
|
|
- [ ] Seed variations detect non-determinism
|
||
|
|
- [ ] Flake report includes confidence score
|
||
|
|
- [ ] No config required (always on in test)
|
||
|
|
- [ ] Does not affect passing tests
|
||
|
|
|
||
|
|
### 11.3 Mutation
|
||
|
|
- [ ] `mutate()` returns score 0.0-1.0
|
||
|
|
- [ ] Status-code mutator works on simple handlers
|
||
|
|
- [ ] Field-delete mutator removes response fields
|
||
|
|
- [ ] Original handlers are restored after mutation
|
||
|
|
- [ ] Weak routes identified correctly
|
||
|
|
- [ ] Only runs in `NODE_ENV=test`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 12. Documentation Plan
|
||
|
|
|
||
|
|
### 12.1 User Documentation
|
||
|
|
|
||
|
|
**File**: `docs/chaos.md`
|
||
|
|
- Chaos mode overview
|
||
|
|
- Config examples
|
||
|
|
- Interpreting chaos events
|
||
|
|
- Best practices (gradual probability increase)
|
||
|
|
|
||
|
|
**File**: `docs/flake.md`
|
||
|
|
- Flake detection overview
|
||
|
|
- Understanding flake reports
|
||
|
|
- Fixing flaky contracts
|
||
|
|
|
||
|
|
**File**: `docs/mutation.md`
|
||
|
|
- Mutation testing overview
|
||
|
|
- Interpreting mutation scores
|
||
|
|
- Writing contracts that catch bugs
|
||
|
|
- Weak route remediation
|
||
|
|
|
||
|
|
### 12.2 API Documentation
|
||
|
|
|
||
|
|
Update `README.md`:
|
||
|
|
- Add Chaos section with example
|
||
|
|
- Add Flake section (auto-run behavior)
|
||
|
|
- Add Mutation section with score example
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 13. Success Metrics
|
||
|
|
|
||
|
|
| Metric | Target | Measurement |
|
||
|
|
|--------|--------|-------------|
|
||
|
|
| Chaos injection accuracy | >95% | Unit tests |
|
||
|
|
| Flake detection rate | >90% | Synthetic flaky tests |
|
||
|
|
| Mutation kill rate (example API) | >80% | Demo project |
|
||
|
|
| Test suite runtime (with flake) | <2x baseline | Benchmark |
|
||
|
|
| Code coverage (quality/) | >90% | npx c8 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 14. References
|
||
|
|
|
||
|
|
### Codebase Citations
|
||
|
|
|
||
|
|
1. **TestConfig**: `src/types.ts:218-223`
|
||
|
|
2. **TestResult/TestSuite**: `src/types.ts:254-291`
|
||
|
|
3. **runPetitTests**: `src/test/petit-runner.ts:166-428`
|
||
|
|
4. **executeHttp**: `src/infrastructure/http-executor.ts:63-145`
|
||
|
|
5. **Extension hooks**: `src/extension/types.ts:145-174`
|
||
|
|
6. **Extension registry**: `src/extension/registry.ts:268-294`
|
||
|
|
7. **Plugin entry**: `src/plugin/index.ts:48-69`
|
||
|
|
8. **Environment**: `process.env.NODE_ENV` (standard Node.js)
|
||
|
|
|
||
|
|
### External Dependencies (Potential)
|
||
|
|
|
||
|
|
- `meriyah`: ES2020 parser for AST mutation
|
||
|
|
- `astring`: AST to code generator
|
||
|
|
- `acorn`: Alternative parser (if meriyah insufficient)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 15. Approval Checklist
|
||
|
|
|
||
|
|
- [ ] Architecture reviewed
|
||
|
|
- [ ] File structure agreed
|
||
|
|
- [ ] API surface approved (config vs methods)
|
||
|
|
- [ ] Test strategy accepted
|
||
|
|
- [ ] Schedule realistic
|
||
|
|
- [ ] Risk analysis complete
|
||
|
|
- [ ] Documentation plan approved
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
*Document Version: 1.0*
|
||
|
|
*Author: APOPHIS Architecture Team*
|
||
|
|
*Date: 2026-04-25*
|