(mess) Stuffing commit.
CI / test (20.x) (push) Failing after 1m55s
CI / test (22.x) (push) Failing after 35s

This commit is contained in:
John Dvorak
2026-05-20 16:09:43 -07:00
parent 457a3495ab
commit 31530fe899
18 changed files with 3127 additions and 137 deletions
+14 -14
View File
@@ -10,11 +10,10 @@ Chaos testing applies the invariant-driven verification approach from [Invariant
const result = await fastify.apophis.contract({
runs: 50,
chaos: {
probability: 0.1, // 10% of requests get chaos
delay: { probability: 1, minMs: 100, maxMs: 500 },
error: { probability: 1, statusCode: 503 },
dropout: { probability: 1 },
corruption: { probability: 1 },
delay: { probability: 0.1, minMs: 100, maxMs: 500 },
error: { probability: 0.1, statusCode: 503 },
dropout: { probability: 0.05 },
corruption: { probability: 0.1 },
},
});
```
@@ -26,7 +25,6 @@ const result = await fastify.apophis.contract({
Adds artificial latency. Tests timeout contracts:
```apostl
timeout_occurred(this) == false
response_time(this) < 1000
```
@@ -37,7 +35,8 @@ response_time(this) < 1000
Forces HTTP status codes. Tests error-handling contracts:
```apostl
if status:503 then response_body(this).retry_after != null
// Behavioral: when the service is unavailable, the client receives a valid retry signal
if status:503 then response_headers(this).retry-after > 0
```
### Dropout
@@ -45,7 +44,8 @@ if status:503 then response_body(this).retry_after != null
Simulates network failure (status 0). Tests fallback contracts:
```apostl
status:200 || status:0
// Behavioral: partial failure must still return previously cached data
if status:0 then response_body(this).cached_data == previous(response_body(GET /cache/{request_params(this).key}))
```
### Corruption
@@ -53,7 +53,8 @@ status:200 || status:0
Mutates response bodies. Tests parsing robustness:
```apostl
response_body(this).id != null
// Behavioral: corrupted requests maintain traceability for debugging
if status:400 then response_body(this).request_id == request_headers(this).x-request-id
```
## Corruption Strategies
@@ -104,7 +105,7 @@ Failed tests include chaos events in diagnostics:
```json
{
"statusCode": 503,
"error": "Contract violation: status:200",
"error": "Contract violation: if status:503 then response_headers(this).retry-after > 0",
"chaosEvents": [
{
"type": "error",
@@ -122,7 +123,7 @@ Failed tests include chaos events in diagnostics:
1. **Start small**: `probability: 0.05` (5% of requests)
2. **Test one failure mode at a time**: Comment out other chaos types
3. **Verify contracts handle chaos**: `if status:503 then response_body(this).error != null`
3. **Verify contracts handle chaos**: `if status:503 then response_code(GET /health) == 200`
4. **Use seeds for reproducibility**: `seed: 42` makes chaos deterministic
## Example: Testing Retry Logic
@@ -131,7 +132,7 @@ Failed tests include chaos events in diagnostics:
fastify.get('/data', {
schema: {
'x-ensures': [
'if status:503 then response_headers(this).retry-after != null',
'if status:503 then response_headers(this).retry-after > 0',
'redirect_count(this) <= 3',
],
},
@@ -140,8 +141,7 @@ fastify.get('/data', {
// Test
const result = await fastify.apophis.contract({
chaos: {
probability: 0.2,
error: { probability: 1, statusCode: 503 },
error: { probability: 0.2, statusCode: 503 },
},
});
```
+40 -26
View File
@@ -5,21 +5,25 @@ import crypto from 'crypto'
const fastify = Fastify()
await fastify.register(apophisPlugin, {
runtime: 'error', // Validate contracts on every request
cleanup: true, // Auto-cleanup resources on exit
runtime: 'error',
cleanup: true,
})
// In-memory store for demo
const users = new Map<string, { id: string; email: string; name: string }>()
// CREATE — constructor
// Behavioral: the created user must be retrievable.
// Note: we do not write 'status:201' or 'response_body(this).id != null'.
// The schema already validates status codes and required fields.
// Contracts should test behavior across operations, not structure.
fastify.post('/users', {
schema: {
'x-category': 'constructor',
'x-ensures': [
'status:201',
'response_body(this).id != null',
'response_body(this).email == request_body(this).email',
// Round-trip: the server returns exactly what we sent (no mutation, no drops)
'response_body(this) == request_body(this)',
// Cross-route: the created user must be retrievable
'response_code(GET /users/{response_body(this).id}) == 200',
],
body: {
type: 'object',
@@ -49,19 +53,21 @@ fastify.post('/users', {
})
// READ — observer
// Behavioral: the returned user must match the requested id.
fastify.get('/users/:id', {
schema: {
'x-category': 'observer',
'x-requires': ['users:id'],
'x-requires': [
// Precondition: the user must exist for this read to be valid
'response_code(GET /users/{request_params(this).id}) == 200'
],
'x-ensures': [
'status:200',
// The returned id must match the requested id (no mix-up)
'response_body(this).id == request_params(this).id',
],
params: {
type: 'object',
properties: {
id: { type: 'string' }
},
properties: { id: { type: 'string' } },
required: ['id']
},
response: {
@@ -84,19 +90,21 @@ fastify.get('/users/:id', {
})
// UPDATE — mutator
// Behavioral: after update, the change must be visible on read.
fastify.put('/users/:id', {
schema: {
'x-category': 'mutator',
'x-requires': ['users:id'],
'x-requires': [
// The user must exist before updating
'response_code(GET /users/{request_params(this).id}) == 200'
],
'x-ensures': [
'status:200',
'response_body(this).id == request_params(this).id',
// Cross-route: after update, reading the user shows the new data
'response_body(GET /users/{request_params(this).id}).email == request_body(this).email',
],
params: {
type: 'object',
properties: {
id: { type: 'string' }
},
properties: { id: { type: 'string' } },
required: ['id']
},
body: {
@@ -132,34 +140,40 @@ fastify.put('/users/:id', {
})
// DELETE — destructor
// Behavioral: after deletion, the user must no longer exist.
fastify.delete('/users/:id', {
schema: {
'x-category': 'destructor',
'x-requires': ['users:id'],
'x-ensures': ['status:204'],
'x-requires': [
// The user must exist before deleting
'response_code(GET /users/{request_params(this).id}) == 200'
],
'x-ensures': [
// After deletion, the user is gone
'response_code(GET /users/{request_params(this).id}) == 404',
// The deleted user data is returned (matches pre-deletion read)
'response_body(this) == previous(response_body(GET /users/{request_params(this).id}))',
],
params: {
type: 'object',
properties: {
id: { type: 'string' }
},
properties: { id: { type: 'string' } },
required: ['id']
}
}
}, async (req, reply) => {
const user = users.get(req.params.id)
users.delete(req.params.id)
reply.status(204)
reply.status(200)
return user
})
await fastify.ready()
// Run contract tests (all non-utility routes, property-based)
const result = await fastify.apophis.contract({ runs: 50 })
console.log('Contract tests:', result.summary)
// Run stateful tests (constructor→mutator→destructor sequences)
const stateful = await fastify.apophis.stateful({ runs: 50, seed: 42 })
console.log('Stateful tests:', stateful.summary)
// Validate a single route
const check = await fastify.apophis.check('POST', '/users')
console.log('POST /users check:', check.ok ? 'PASS' : 'FAIL')
+13 -4
View File
@@ -6,18 +6,27 @@ const fastify = Fastify()
// APOPHIS auto-registers @fastify/swagger
await fastify.register(apophisPlugin, {})
fastify.get('/health', {
// Behavioral contract: what you send is what you get back.
// This is not a structural test — the schema already validates shape.
// This checks that the server does not mutate or drop fields.
fastify.post('/echo', {
schema: {
'x-category': 'observer',
'x-ensures': ['status:200'],
'x-ensures': [
'response_body(this) == request_body(this)'
],
body: {
type: 'object',
properties: { message: { type: 'string' } }
},
response: {
200: {
type: 'object',
properties: { status: { type: 'string' } }
properties: { message: { type: 'string' } }
}
}
}
}, async () => ({ status: 'ok' }))
}, async (req) => req.body)
await fastify.ready()
+2 -2
View File
@@ -280,8 +280,8 @@ app.get('/users/:id', {
type: 'object',
properties: { id: { type: 'string' } },
'x-ensures': [
// Standard APOSTL + extension predicates
'status:200',
// Behavioral: returned user must match the requested id
'response_body(this).id == request_params(this).id',
'graph_check(this).user.can_read_user == true',
'partial_graph(this).tenant.accessible == true',
],
@@ -284,8 +284,8 @@ fastify.get('/api/resource', {
'x-ensures': [
'timeout_occurred(this) == false',
'redirect_count(this) == 0',
'response_code(this) == 200',
'response_body(this).id != null',
// Behavioral: created resource must be retrievable
'response_code(GET /api/resource/{response_body(this).id}) == 200',
]
}
}, handler)
+42
View File
@@ -94,6 +94,48 @@ apophis replay --artifact reports/apophis/failure-2026-04-28T12-30-22Z.json
Fix the bug in your handler. Re-run verify. The failure should now pass.
## Behavioral vs Structural Contracts
APOPHIS contracts should verify **behavior**, not structure. Fastify and `@fastify/swagger` already enforce status codes, required fields, and types. Behavioral contracts catch what schemas cannot:
| Structural (avoid) | Behavioral (prefer) |
|---|---|
| `status:200` | `response_body(this) == request_body(this)` |
| `response_body(this).id != null` | `response_code(GET /users/{response_body(this).id}) == 200` |
| `response_body(this).name != null` | `response_body(GET /users/{id}).name == previous(response_body(this).name)` |
**Good behavioral patterns (from the paper):**
- **Constructor precondition**: Resource must not exist before creation
```apostl
response_code(GET /users/{request_body(this).email}) == 404
```
- **Round-trip equality**: POST response matches the request body
```apostl
response_body(this) == request_body(this)
```
- **Cross-route retrievability**: Creating a resource makes it readable via GET
```apostl
response_code(GET /users/{response_body(this).id}) == 200
```
- **State-change verification**: DELETE causes subsequent GET to return 404
```apostl
response_code(GET /users/{request_params(this).id}) == 404
```
- **Previous state preservation**: DELETE returns the last known state
```apostl
response_body(this) == previous(response_body(GET /users/{request_params(this).id}))
```
- **Invariant over collections**: All resources satisfy a cross-resource constraint
```apostl
for t in response_body(GET /tournaments) :-
response_body(GET /tournaments/{t.id}/players).length <= t.capacity
```
**Anti-patterns to avoid:**
- Checking status codes (handled by schema validation)
- Checking field existence (handled by schema validation)
- Checking field types (handled by schema validation)
## Next Steps
- Add more routes to your profile: `apophis verify --profile quick --routes "POST /users,PUT /users/:id"`
+7 -5
View File
@@ -58,11 +58,12 @@ Scenarios are multi-step flows with capture and rebind:
```javascript
await fastify.apophis.scenario({
name: 'oauth-basic',
steps: [
steps: [
{
name: 'authorize',
request: { method: 'GET', url: '/oauth/authorize?client_id=web&response_type=code' },
expect: ['status:200', 'response_payload(this).code != null'],
request: { method: 'GET', url: '/oauth/authorize?client_id=web&response_type=code&state=abc123' },
// Behavioral: state parameter round-trips for CSRF protection
expect: ['response_payload(this).state == request_query(this).state'],
capture: { code: 'response_payload(this).code' }
},
{
@@ -70,9 +71,10 @@ await fastify.apophis.scenario({
request: {
method: 'POST',
url: '/oauth/token',
form: { grant_type: 'authorization_code', code: '$authorize.code' }
form: { grant_type: 'authorization_code', code: '$authorize.code', scope: 'read' }
},
expect: ['status:200', 'response_payload(this).access_token != null']
// Behavioral: issued token preserves the requested scope
expect: ['response_payload(this).scope == request_body(this).scope']
}
]
})
+226
View File
@@ -0,0 +1,226 @@
# Quality Engines
APOPHIS includes three quality engines for advanced testing: chaos injection, flake detection, and mutation testing. All require `NODE_ENV=test`.
## Chaos Injection
Inject controlled failures into contract tests to validate resilience guarantees. Chaos events are generated by fast-check alongside test data, making them shrinkable — when a test fails, fast-check finds the minimal chaos event that causes the failure.
### Usage
```javascript
const result = await fastify.apophis.contract({
runs: 50,
chaos: {
delay: { probability: 0.1, minMs: 100, maxMs: 500 },
error: { probability: 0.1, statusCode: 503 },
dropout: { probability: 0.05 },
corruption: { probability: 0.1 },
},
})
```
### Event Types
| Type | Effect | Tests |
|------|--------|-------|
| `delay` | Artificial latency | `response_time(this) < 1000` |
| `error` | Forces HTTP status code | Error-handling contracts |
| `dropout` | Network failure (status 0 or 504) | Fallback contracts |
| `corruption` | Mutates response bodies | Parsing robustness |
### Corruption Strategies
| Strategy | Effect |
|----------|--------|
| `truncate` | Cuts response body in half |
| `malformed` | Returns invalid JSON (`{"broken":`) |
| `field-corrupt` | Sets a random field to `null` |
### Programmatic API
```javascript
import {
applyChaosToExecution,
createChaosEventArbitrary,
formatChaosEvents,
} from 'apophis-fastify'
// Apply pre-generated chaos events to a context
const result = applyChaosToExecution(ctx, events)
// Generate deterministic chaos events
const arb = createChaosEventArbitrary(config, contractNames)
const events = fc.sample(arb, { numRuns: 1, seed: 42 })[0]
// Format for diagnostics
console.log(formatChaosEvents(events))
```
### Best Practices
1. Start small: `probability: 0.05` (5% of requests)
2. Test one failure mode at a time
3. Verify contracts handle chaos: `if status:503 then response_code(GET /health) == 200`
4. Use seeds for reproducibility: `seed: 42`
## Flake Detection
Automatically rerun failing tests with varied seeds to detect non-deterministic contracts. A "flake" is a test that fails on one run but passes on another with the same or different seed.
### Usage
```javascript
import { FlakeDetector } from 'apophis-fastify'
const detector = new FlakeDetector({
sameSeedReruns: 1, // Rerun with same seed
seedVariations: 3, // Try 3 additional seeds
})
const report = await detector.detectFlake(
originalFailingResult,
async (seed) => {
const suite = await fastify.apophis.contract({ seed })
return { passed: suite.summary.failed === 0 }
},
originalSeed
)
if (report.isFlaky) {
console.log(`Flaky with ${report.confidence} confidence`)
console.log('Reruns:', report.reruns)
}
```
### Report Structure
```javascript
{
isFlaky: true,
confidence: 'high', // 'high' | 'medium' | 'low'
reruns: [
{ seed: 42, passed: false },
{ seed: 43, passed: true },
]
}
```
### Confidence Scoring
| Pass Rate | Confidence |
|-----------|------------|
| 0% pass | `high` (deterministic failure) |
| < 50% pass | `medium` |
| >= 50% pass | `low` (likely flaky) |
## Mutation Testing
Measure contract strength by injecting synthetic bugs. A "mutation" is a small change to a contract (e.g., flip `==` to `!=`). If the test suite catches the mutation (fails), the mutation is "killed". If it passes, the mutation "survives" — indicating weak coverage.
### Usage
```javascript
import { runMutationTesting } from 'apophis-fastify/quality/mutation'
const report = await runMutationTesting(fastify, {
runs: 10,
seed: 42,
maxMutationsPerContract: 5,
routes: ['/items'], // Optional: only test these routes
})
console.log(`Mutation score: ${report.score}%`)
console.log(`Killed: ${report.killed}, Survived: ${report.survived}`)
console.log('Weak contracts:', report.weakContracts)
```
### Mutation Operators
| Type | Example |
|------|---------|
| `flip-operator` | `== 201``!= 201` |
| `change-number` | `== 200``== 201` |
| `remove-clause` | `A && B``A` |
| `negate-boolean` | `== true``== false` |
| `swap-variable` | `response_body``request_body` |
| `remove-ensures` | Remove one ensures clause entirely |
### Report Structure
```javascript
{
score: 85, // 0-100
killed: 17,
survived: 3,
durationMs: 4500,
weakContracts: ['POST /items'], // Routes where no mutations were killed
mutations: [
{
mutation: {
id: 'm0',
route: 'POST /items',
original: 'response_code(this) == 201',
mutated: 'response_code(this) != 201',
type: 'flip-operator',
},
killed: true,
durationMs: 120,
}
]
}
```
### Single Mutation Test
Test a specific mutation without running the full suite:
```javascript
import { testMutation } from 'apophis-fastify/quality/mutation'
const killed = await testMutation(fastify, contract, mutation, {
runs: 10,
seed: 42,
})
```
## Environment Guard
All quality engines require `NODE_ENV=test`:
```
Error: chaos is only available in test environment.
Set NODE_ENV=test to enable quality features.
```
This prevents accidental execution in production or development.
## Integration Example
Run all three engines in a CI pipeline:
```javascript
// 1. Standard contract tests
const suite = await fastify.apophis.contract({ runs: 50, seed: 42 })
// 2. Chaos tests
const chaosSuite = await fastify.apophis.contract({
runs: 50,
seed: 42,
chaos: { error: { probability: 0.1, statusCode: 503 } },
})
// 3. Flake detection on failures
for (const test of suite.tests.filter(t => !t.ok)) {
const report = await detector.detectFlake(test, rerunFn, 42)
if (report.isFlaky) {
console.warn(`Flaky test detected: ${test.name}`)
}
}
// 4. Mutation testing
const mutationReport = await runMutationTesting(fastify, { runs: 10 })
if (mutationReport.score < 80) {
console.warn(`Low mutation score: ${mutationReport.score}%`)
}
```