fix: harden engine, enrich failure diagnostics, close adoption gaps
- P0: CLI verify now honors test budget with seeded multi-sample - P0: Observe sampling enforced via Math.random() gate in hook-validator - P1: Remove misleading undici-mock-agent isolation option - P1: Qualify reuses shared discoverRouteDetails() with warnings - P1: Chaos/scenario config exposed via preset schema - P1: README/docs limitations updated to current state - P2: Nested response annotations prefer 2xx deterministically - P2: --changed documented as heuristic in verify.md - Add observe sink tests (sampling 0/1, sink failure non-interference) - Add verify runs regression tests (scale, determinism, variants) - Add configured-scenario qualify test (independent of OAuth fixture) - Add coverageBreakdown to qualify artifacts (per-gate route coverage) - Add production-style observe example with real sink in docs/observe.md - Add nightly/staging vs PR gating guidance to docs/qualify.md - Enrich VerifyFailure with formula-aware diagnostics: status:201 => 'HTTP 200', body field checks => actual values - Remove stale observe CLI activation message - Document outbound mocks as process-global in getting-started.md - Refresh APOPHIS_ADOPTION_AUDIT.md with current state 903 tests pass, build clean, typecheck clean.
This commit is contained in:
+157
-45
@@ -1,99 +1,211 @@
|
||||
/**
|
||||
* App loader utility for CLI commands.
|
||||
* Handles various app export patterns and module systems.
|
||||
*
|
||||
* Supports auto-detection of entrypoint files:
|
||||
* app.js, app.ts, server.js, server.ts, index.js, index.ts,
|
||||
* src/app.js, src/app.ts, src/server.js, src/server.ts,
|
||||
* src/index.js, src/index.ts
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve, extname } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
function isTsxAvailable(): boolean {
|
||||
return process.execArgv.some(arg => arg.includes('tsx') || arg.includes('ts-node')) ||
|
||||
process.argv[0]?.includes('tsx') ||
|
||||
!!process.env.TSX_TSCONFIG_PATH
|
||||
}
|
||||
|
||||
export const APP_CANDIDATES = [
|
||||
'app.js',
|
||||
'app.ts',
|
||||
'server.js',
|
||||
'server.ts',
|
||||
'index.js',
|
||||
'index.ts',
|
||||
'src/app.js',
|
||||
'src/app.ts',
|
||||
'src/server.js',
|
||||
'src/server.ts',
|
||||
'src/index.js',
|
||||
'src/index.ts',
|
||||
]
|
||||
|
||||
export interface LoadedApp {
|
||||
fastify: unknown
|
||||
source: 'default' | 'named' | 'commonjs'
|
||||
entrypoint: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a Fastify app from app.js in the given directory.
|
||||
* Supports:
|
||||
* Find the first existing app entrypoint file in the given directory.
|
||||
* Returns the resolved absolute path, or null if no candidate exists.
|
||||
*/
|
||||
export function findAppFile(cwd: string): string | null {
|
||||
for (const candidate of APP_CANDIDATES) {
|
||||
const fullPath = resolve(cwd, candidate)
|
||||
if (existsSync(fullPath)) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a Fastify app from the given directory.
|
||||
*
|
||||
* Auto-detects the entrypoint by searching APP_CANDIDATES for the first
|
||||
* existing file. Supports:
|
||||
* - ESM default export: export default fastifyInstance
|
||||
* - ESM named export: export const createApp = () => fastifyInstance
|
||||
* - CommonJS: module.exports = fastifyInstance
|
||||
* - CommonJS named: exports.createApp = () => fastifyInstance
|
||||
*
|
||||
* If an explicit entrypoint is provided, it takes precedence over auto-detection.
|
||||
* For .ts entrypoints, a clear error message suggests installing tsx.
|
||||
*
|
||||
* After loading the app, this function ensures route discovery works even if the
|
||||
* APOPHIS plugin is not registered or was registered after routes.
|
||||
*/
|
||||
export async function loadApp(cwd: string): Promise<LoadedApp> {
|
||||
const appPath = resolve(cwd, 'app.js')
|
||||
const appUrl = pathToFileURL(appPath).href + '?t=' + Date.now()
|
||||
export async function loadApp(cwd: string, entrypoint?: string): Promise<LoadedApp> {
|
||||
const resolvedEntrypoint = entrypoint
|
||||
? resolve(cwd, entrypoint)
|
||||
: findAppFile(cwd)
|
||||
|
||||
if (!resolvedEntrypoint) {
|
||||
throw new AppLoadError(
|
||||
`No Fastify app entrypoint found. Searched for: ${APP_CANDIDATES.join(', ')}.\n` +
|
||||
'Create an app.js, server.js, or similar that exports a Fastify instance.',
|
||||
'not_found',
|
||||
)
|
||||
}
|
||||
|
||||
const ext = extname(resolvedEntrypoint)
|
||||
const isTypeScript = ext === '.ts'
|
||||
|
||||
if (isTypeScript && !isTsxAvailable()) {
|
||||
throw new AppLoadError(
|
||||
`TypeScript entrypoint ${resolvedEntrypoint} requires a TS loader.\n` +
|
||||
'Install tsx and run:\n' +
|
||||
' npm install -D tsx\n' +
|
||||
' npx tsx ./node_modules/.bin/apophis verify\n\n' +
|
||||
'Or convert your entrypoint to JavaScript.',
|
||||
'import_failed',
|
||||
)
|
||||
}
|
||||
|
||||
const appUrl = pathToFileURL(resolvedEntrypoint).href + '?t=' + Date.now()
|
||||
|
||||
let appModule: Record<string, unknown>
|
||||
try {
|
||||
appModule = await import(appUrl) as Record<string, unknown>
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : String(err)
|
||||
if (isTypeScript) {
|
||||
throw new AppLoadError(
|
||||
`Cannot load TypeScript entrypoint ${resolvedEntrypoint}: ${errMessage}\n\n` +
|
||||
'TypeScript entrypoints require tsx. Install tsx and run:\n' +
|
||||
' npm install -D tsx\n' +
|
||||
' npx tsx ./node_modules/.bin/apophis verify\n\n' +
|
||||
'Or convert your entrypoint to JavaScript.',
|
||||
'import_failed',
|
||||
)
|
||||
}
|
||||
throw new AppLoadError(
|
||||
`Cannot load app.js: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`Cannot load ${resolvedEntrypoint}: ${errMessage}`,
|
||||
'import_failed',
|
||||
)
|
||||
}
|
||||
|
||||
// Try default export first
|
||||
let fastify: unknown
|
||||
let source: 'default' | 'named' | 'commonjs' = 'default'
|
||||
|
||||
if (appModule.default && isFastifyInstance(appModule.default)) {
|
||||
return { fastify: appModule.default, source: 'default' }
|
||||
}
|
||||
fastify = appModule.default
|
||||
source = 'default'
|
||||
} else {
|
||||
let found = false
|
||||
for (const [key, value] of Object.entries(appModule)) {
|
||||
if (key === 'default') continue
|
||||
|
||||
// Try named exports that look like Fastify instances or factory functions
|
||||
for (const [key, value] of Object.entries(appModule)) {
|
||||
if (key === 'default') continue
|
||||
if (isFastifyInstance(value)) {
|
||||
fastify = value
|
||||
source = 'named'
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
if (isFastifyInstance(value)) {
|
||||
return { fastify: value, source: 'named' }
|
||||
}
|
||||
|
||||
// Try calling factory functions
|
||||
if (typeof value === 'function' && !isClass(value)) {
|
||||
try {
|
||||
const result = await value()
|
||||
if (isFastifyInstance(result)) {
|
||||
return { fastify: result, source: 'named' }
|
||||
if (typeof value === 'function' && !isClass(value)) {
|
||||
try {
|
||||
const result = await value()
|
||||
if (isFastifyInstance(result)) {
|
||||
fastify = result
|
||||
source = 'named'
|
||||
found = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Factory function failed, try next
|
||||
}
|
||||
} catch {
|
||||
// Factory function failed, try next
|
||||
}
|
||||
}
|
||||
if (!found && isFastifyInstance(appModule)) {
|
||||
fastify = appModule
|
||||
source = 'commonjs'
|
||||
found = true
|
||||
}
|
||||
if (!found) {
|
||||
throw new AppLoadError(
|
||||
`No Fastify instance found in ${resolvedEntrypoint}. ` +
|
||||
'Ensure the file exports a Fastify instance or a factory function.\n\n' +
|
||||
'Supported patterns:\n' +
|
||||
' export default app\n' +
|
||||
' export { app }\n' +
|
||||
' export const createApp = () => app\n' +
|
||||
' module.exports = app',
|
||||
'no_fastify',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If module itself is a Fastify instance (CommonJS)
|
||||
if (isFastifyInstance(appModule)) {
|
||||
return { fastify: appModule, source: 'commonjs' }
|
||||
}
|
||||
await ensureRouteDiscovery(fastify as Record<string, unknown>)
|
||||
|
||||
throw new AppLoadError(
|
||||
'No Fastify instance found in app.js. Ensure app.js exports a Fastify instance or a factory function.',
|
||||
'no_fastify',
|
||||
)
|
||||
return { fastify, source, entrypoint: resolvedEntrypoint }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value looks like a Fastify instance.
|
||||
* Install a lightweight onRoute hook for route discovery.
|
||||
* This runs before avvio's boot phase, so routes registered
|
||||
* inside deferred plugins will be captured even if apophis
|
||||
* is not registered first.
|
||||
*/
|
||||
async function ensureRouteDiscovery(f: Record<string, unknown>): Promise<void> {
|
||||
if (typeof f.addHook !== 'function') return
|
||||
|
||||
try {
|
||||
const { installRouteDiscovery } = await import('../../infrastructure/discovery-hook.js')
|
||||
installRouteDiscovery(f as { addHook: Function })
|
||||
} catch {
|
||||
// discovery module not importable, skip auto-capture
|
||||
}
|
||||
}
|
||||
|
||||
function isFastifyInstance(value: unknown): boolean {
|
||||
return value !== null &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as Record<string, unknown>).ready === 'function'
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const obj = value as Record<string, unknown>
|
||||
return typeof obj.ready === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a function is a class constructor.
|
||||
*/
|
||||
function isClass(fn: unknown): boolean {
|
||||
return typeof fn === 'function' &&
|
||||
fn.toString().startsWith('class ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Error type for app loading failures.
|
||||
*/
|
||||
export class AppLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: 'import_failed' | 'no_fastify',
|
||||
public readonly code: 'import_failed' | 'no_fastify' | 'not_found',
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppLoadError'
|
||||
|
||||
Reference in New Issue
Block a user