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

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
parent d278c4b105
commit 3ac1daf7e9
82 changed files with 3902 additions and 1098 deletions
@@ -1,7 +1,6 @@
/**
* APOPHIS configuration for broken-behavior fixture.
*/
export default {
mode: "verify",
profiles: {
@@ -15,7 +14,6 @@ export default {
presets: {
"safe-ci": {
name: "safe-ci",
depth: "quick",
timeout: 5000,
parallel: false,
chaos: false,
@@ -2,11 +2,9 @@
* LEGACY APOPHIS configuration (old-style, for migration tests).
* This uses deprecated field names that should be detected by `apophis migrate`.
*/
export default {
// Deprecated: 'mode' used to be 'testMode'
testMode: "verify",
// Deprecated: 'profiles' used to be 'testProfiles'
testProfiles: {
quick: {
@@ -17,7 +15,6 @@ export default {
routeFilter: ["GET /legacy"],
},
},
// Deprecated: 'presets' used to be 'testPresets'
testPresets: {
"safe-ci": {
@@ -28,7 +25,6 @@ export default {
maxDuration: 5000,
},
},
// Deprecated: 'environments' used to be 'envPolicies'
envPolicies: {
local: {
@@ -2,7 +2,6 @@
* Root-level APOPHIS config for monorepo.
* Packages can override with their own configs.
*/
export default {
mode: "verify",
profiles: {
@@ -20,7 +19,6 @@ export default {
presets: {
"safe-ci": {
name: "safe-ci",
depth: "quick",
timeout: 5000,
parallel: false,
chaos: false,
@@ -1,7 +1,6 @@
/**
* APOPHIS configuration for observe-config fixture.
*/
export default {
mode: "observe",
profiles: {
@@ -15,7 +14,6 @@ export default {
presets: {
"observe-safe": {
name: "observe-safe",
depth: "quick",
timeout: 5000,
parallel: false,
chaos: false,
@@ -1,7 +1,6 @@
/**
* APOPHIS configuration for protocol-lab fixture.
*/
export default {
mode: "qualify",
profiles: {
@@ -15,7 +14,6 @@ export default {
presets: {
deep: {
name: "deep",
depth: "deep",
timeout: 30000,
parallel: false,
chaos: true,
@@ -1,7 +1,6 @@
/**
* APOPHIS configuration for tiny-fastify fixture.
*/
export default {
mode: "verify",
profiles: {
@@ -15,7 +14,6 @@ export default {
presets: {
"safe-ci": {
name: "safe-ci",
depth: "quick",
timeout: 5000,
parallel: false,
chaos: false,
@@ -11,7 +11,6 @@ export default {
presets: {
"safe-ci": {
name: "safe-ci",
depth: "quick",
timeout: 5000,
parallel: false,
chaos: false,
@@ -11,7 +11,6 @@ export default {
presets: {
"safe-ci": {
name: "safe-ci",
depth: "quick",
timeout: 5000,
parallel: false,
chaos: false,
@@ -11,7 +11,6 @@ export default {
presets: {
"safe-ci": {
name: "safe-ci",
depth: "quick",
timeout: 5000,
parallel: false,
chaos: false,
+2 -2
View File
@@ -116,8 +116,8 @@ export async function checkRouteDiscovery(options: RouteCheckOptions): Promise<R
name: 'route-discovery',
status: 'fail',
message: `App file ${appFile} does not export a valid object.`,
detail: 'Ensure the app file exports a Fastify instance as default.',
remediation: 'Export your Fastify instance as default: export default app;',
detail: 'Ensure the app file exports a Fastify instance or a factory function.',
remediation: 'Export your Fastify instance: export default app; or export const createApp = () => app; or module.exports = app;',
mode: 'all',
};
}
+11 -1
View File
@@ -26,6 +26,16 @@ import { runDocsChecks } from './checks/docs.js';
import { renderJson } from '../../renderers/json.js';
// Deterministic string-to-seed hash (FNV-1a)
function hashStringToSeed(str: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return Math.abs(hash >>> 0)
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -203,7 +213,7 @@ async function runPackageChecks(
}
// 6. Determinism trust signal
const testSeed = Math.floor(Math.random() * 0x7fffffff);
const testSeed = hashStringToSeed(packageName + cwd);
checks.push({
name: 'determinism',
status: 'pass',
-4
View File
@@ -17,7 +17,6 @@ export interface ScaffoldResult {
export function safeCiScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'safe-ci',
depth: 'quick',
timeout: 5000,
parallel: false,
chaos: false,
@@ -95,7 +94,6 @@ If \`apophis verify\` says "No behavioral contracts found", it means your routes
export function platformObserveScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'platform-observe',
depth: 'standard',
timeout: 10000,
parallel: true,
chaos: false,
@@ -180,7 +178,6 @@ This project was scaffolded with \`apophis init --preset platform-observe\`.
export function llmSafeScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'llm-safe',
depth: 'quick',
timeout: 3000,
parallel: false,
chaos: false,
@@ -258,7 +255,6 @@ If \`apophis verify\` says "No behavioral contracts found", it means your routes
export function protocolLabScaffold(): ScaffoldResult {
const preset: PresetDefinition = {
name: 'protocol-lab',
depth: 'deep',
timeout: 15000,
parallel: false,
chaos: true,
+2 -27
View File
@@ -19,7 +19,7 @@
import type { CliContext } from '../../core/context.js'
import { loadConfig } from '../../core/config-loader.js'
import { PolicyEngine, detectEnvironment } from '../../core/policy-engine.js'
import { resolveGenerationProfileOverride, GenerationProfileResolutionError } from '../../core/generation-profile.js'
import { SUCCESS, BEHAVIORAL_FAILURE, USAGE_ERROR, INTERNAL_ERROR } from '../../core/exit-codes.js'
import type { CommandResult, Artifact, FailureRecord } from '../../core/types.js'
import { classifyError, ErrorTaxonomy } from '../../core/error-taxonomy.js'
@@ -54,13 +54,6 @@ function isReplayCompatibleRoute(route: string): boolean {
return ROUTE_IDENTITY_PATTERN.test(route)
}
function coerceDepth(value: unknown): TestConfig['depth'] {
if (value === 'quick' || value === 'standard' || value === 'thorough') {
return value
}
return 'standard'
}
function coerceTimeout(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
@@ -71,7 +64,6 @@ function coerceTimeout(value: unknown): number | undefined {
export interface QualifyOptions {
profile?: string
generationProfile?: string
seed?: number
config?: string
cwd?: string
@@ -529,7 +521,6 @@ export async function qualifyCommand(
): Promise<CommandResult> {
const {
profile,
generationProfile,
seed: explicitSeed,
config: configPath,
cwd,
@@ -558,7 +549,6 @@ export async function qualifyCommand(
}
const config = loadResult.config
const resolvedGenerationProfile = resolveGenerationProfileOverride(generationProfile, config)
// 2. Run policy engine checks
const policyEngine = new PolicyEngine({
@@ -600,12 +590,9 @@ export async function qualifyCommand(
// 6. Build stateful config
const presetName = profileDef?.preset
const preset = presetName ? config.presets?.[presetName] : undefined
const presetDepth = coerceDepth((preset as { depth?: unknown } | undefined)?.depth)
const presetTimeout = coerceTimeout((preset as { timeout?: unknown } | undefined)?.timeout)
const statefulConfig: TestConfig | undefined = gates.stateful
? {
depth: presetDepth,
generationProfile: resolvedGenerationProfile,
seed,
timeout: presetTimeout,
routes: profileDef?.routes,
@@ -642,7 +629,7 @@ export async function qualifyCommand(
}
return {
exitCode: USAGE_ERROR,
message: 'No Fastify app found. Ensure app.js exports a Fastify instance.',
message: 'No Fastify app found. Ensure app.js exports a Fastify instance or a factory function.\n\nSupported patterns:\n export default app\n export const createApp = () => app\n module.exports = app',
}
}
@@ -752,12 +739,6 @@ export async function qualifyCommand(
}
}
} catch (error) {
if (error instanceof GenerationProfileResolutionError) {
return {
exitCode: USAGE_ERROR,
message: error.message,
}
}
const message = error instanceof Error ? error.message : String(error)
return {
exitCode: INTERNAL_ERROR,
@@ -780,7 +761,6 @@ export async function handleQualify(
): Promise<number> {
const options: QualifyOptions = {
profile: ctx.options.profile || undefined,
generationProfile: ctx.options.generationProfile,
seed: undefined,
config: ctx.options.config || undefined,
cwd: ctx.cwd,
@@ -798,11 +778,6 @@ export async function handleQualify(
}
}
const generationProfileIdx = args.indexOf('--generation-profile')
if (generationProfileIdx !== -1 && args[generationProfileIdx + 1]) {
options.generationProfile = args[generationProfileIdx + 1]
}
const result = await qualifyCommand(options, ctx)
const format = options.format || ctx.options.format || 'human'
const machineMode = format === 'json' || format === 'ndjson' || format === 'json-summary' || format === 'ndjson-summary'
+15 -3
View File
@@ -11,12 +11,22 @@
*/
import { runStatefulTests } from '../../../test/stateful-runner.js'
import { CleanupManager } from '../../../infrastructure/cleanup-manager.js'
import type {
TestConfig,
TestSuite,
ScopeRegistry,
} from '../../../types.js'
import type { QualifyRunnerDeps, StepTrace } from './runner.js'
const minimalScopeRegistry: ScopeRegistry = {
scopes: new Map(),
defaultScope: { headers: {} },
register() {},
deriveFromRequest() { return { headers: {} } },
getHeaders() { return {} },
}
/**
* Run stateful tests with the given config.
* Wraps the existing stateful runner.
@@ -27,13 +37,15 @@ export async function runStatefulWithTraces(
): Promise<{ result: TestSuite; traces: StepTrace[] }> {
const started = Date.now()
const cleanupManager = new CleanupManager(deps.fastify as any, minimalScopeRegistry, false)
const result = await runStatefulTests(
deps.fastify,
config,
undefined, // cleanupManager — injected if needed by caller
undefined, // scopeRegistry
cleanupManager,
minimalScopeRegistry,
deps.extensionRegistry,
undefined, // pluginContractRegistry
undefined, // pluginContractRegistry — will be passed from runner when available
undefined, // outboundContractRegistry
)
+3 -18
View File
@@ -22,7 +22,7 @@
import type { CliContext } from '../../core/context.js'
import { loadConfig, findWorkspacePackages } from '../../core/config-loader.js'
import { PolicyEngine, detectEnvironment } from '../../core/policy-engine.js'
import { resolveGenerationProfileOverride, GenerationProfileResolutionError } from '../../core/generation-profile.js'
import { SUCCESS, BEHAVIORAL_FAILURE, USAGE_ERROR, INTERNAL_ERROR } from '../../core/exit-codes.js'
import type { CommandResult, Artifact, FailureRecord, RouteResult, WorkspaceRun, WorkspaceResult } from '../../core/types.js'
import { classifyError, ErrorTaxonomy } from '../../core/error-taxonomy.js'
@@ -54,7 +54,6 @@ function isReplayCompatibleRoute(route: string): boolean {
export interface VerifyOptions {
profile?: string
generationProfile?: string
routes?: string
seed?: number
changed?: boolean
@@ -381,7 +380,6 @@ export async function verifyCommand(
): Promise<CommandResult> {
const {
profile,
generationProfile,
routes: routesFlag,
seed: explicitSeed,
changed,
@@ -412,7 +410,6 @@ export async function verifyCommand(
}
const config = loadResult.config
const resolvedGenerationProfile = resolveGenerationProfileOverride(generationProfile, config)
// 2a. Resolve profile — if explicitly requested but missing, list available ones
if (profile && !config.profiles?.[profile]) {
@@ -468,7 +465,7 @@ export async function verifyCommand(
const errorMessage = err instanceof Error ? err.message : String(err)
return {
exitCode: USAGE_ERROR,
message: `No Fastify app found. Ensure app.js exports a Fastify instance.\n\nError: ${errorMessage}\n\nNext:\n Run \`apophis init\` to scaffold a working app.js and config.`,
message: `No Fastify app found. Ensure app.js exports a Fastify instance or a factory function.\n\nSupported patterns:\n export default app\n export const createApp = () => app\n module.exports = app\n\nError: ${errorMessage}\n\nNext:\n Run \`apophis init\` to scaffold a working app.js and config.`,
}
}
@@ -476,7 +473,6 @@ export async function verifyCommand(
const runResult = await runVerify({
fastify: fastify as any,
seed,
generationProfile: resolvedGenerationProfile,
timeout: typeof config.presets?.[loadResult.presetName || '']?.timeout === 'number'
? (config.presets[loadResult.presetName || ''] as { timeout?: number }).timeout
: undefined,
@@ -552,12 +548,7 @@ export async function verifyCommand(
message: `Config validation failed: ${message}`,
}
}
if (error instanceof GenerationProfileResolutionError) {
return {
exitCode: USAGE_ERROR,
message,
}
}
return {
exitCode: INTERNAL_ERROR,
message: `Internal error in verify command: ${message}`,
@@ -579,7 +570,6 @@ export async function handleVerify(
): Promise<number> {
const options: VerifyOptions = {
profile: ctx.options.profile || undefined,
generationProfile: ctx.options.generationProfile,
routes: undefined,
seed: undefined,
changed: false,
@@ -611,11 +601,6 @@ export async function handleVerify(
options.changed = true
}
const generationProfileIdx = args.indexOf('--generation-profile')
if (generationProfileIdx !== -1 && args[generationProfileIdx + 1]) {
options.generationProfile = args[generationProfileIdx + 1]
}
const workspaceMode = args.includes('--workspace')
if (workspaceMode) {
-1
View File
@@ -53,7 +53,6 @@ export interface VerifyRunResult {
export interface VerifyRunnerDeps {
fastify: FastifyInjectInstance
seed: number
generationProfile?: 'quick' | 'standard' | 'thorough'
timeout?: number
routeFilters?: string[]
changed?: boolean
+4 -35
View File
@@ -30,7 +30,6 @@ export interface Config {
environments?: Record<string, EnvironmentPolicy>;
profiles?: Record<string, ProfileDefinition>;
presets?: Record<string, PresetDefinition>;
generationProfiles?: Record<string, 'quick' | 'standard' | 'thorough' | { base: 'quick' | 'standard' | 'thorough' }>;
[key: string]: unknown;
}
@@ -107,11 +106,6 @@ const CONFIG_SCHEMA: Record<string, SchemaField> = {
optional: true,
properties: {},
},
generationProfiles: {
type: 'object',
optional: true,
properties: {},
},
packs: {
type: 'array',
optional: true,
@@ -151,7 +145,6 @@ const PROFILE_SCHEMA: Record<string, SchemaField> = {
// Schema for PresetDefinition values (inside presets.<name>)
const PRESET_SCHEMA: Record<string, SchemaField> = {
name: { type: 'string', optional: false },
depth: { type: 'string', optional: true, enumValues: ['quick', 'standard', 'deep'] },
timeout: { type: 'number', optional: true, min: 0 },
parallel: { type: 'boolean', optional: true },
chaos: { type: 'boolean', optional: true },
@@ -160,12 +153,9 @@ const PRESET_SCHEMA: Record<string, SchemaField> = {
sampling: { type: 'number', optional: true },
blocking: { type: 'boolean', optional: true },
sinks: { type: 'object', optional: true },
runs: { type: 'number', optional: true, min: 1 },
};
const GENERATION_PROFILE_ALIAS_SCHEMA: Record<string, SchemaField> = {
base: { type: 'string', optional: false, enumValues: ['quick', 'standard', 'thorough'] },
}
// ---------------------------------------------------------------------------
// Config discovery
// ---------------------------------------------------------------------------
@@ -259,7 +249,6 @@ function getDynamicContainerSchema(path: string): Record<string, SchemaField> |
if (path === 'profiles') return PROFILE_SCHEMA;
if (path === 'presets') return PRESET_SCHEMA;
if (path === 'environments') return ENVIRONMENT_POLICY_SCHEMA;
if (path === 'generationProfiles') return GENERATION_PROFILE_ALIAS_SCHEMA;
return null;
}
@@ -267,7 +256,7 @@ function getDynamicContainerSchema(path: string): Record<string, SchemaField> |
* Check if a path is inside a dynamic container (e.g., profiles.foo, presets.bar).
*/
function isInsideDynamicContainer(path: string): boolean {
return path.startsWith('profiles.') || path.startsWith('presets.') || path.startsWith('environments.') || path.startsWith('generationProfiles.');
return path.startsWith('profiles.') || path.startsWith('presets.') || path.startsWith('environments.');
}
/**
@@ -379,18 +368,11 @@ export function validateConfigAgainstSchema(
// Handle dynamic containers: profiles, presets, environments
// The keys are user-defined names; their values have specific schemas
const isDynamicContainer = path === 'profiles' || path === 'presets' || path === 'environments' || path === 'generationProfiles';
const isDynamicContainer = path === 'profiles' || path === 'presets' || path === 'environments';
if (!fieldSchema && isDynamicContainer) {
const childSchema = getDynamicContainerSchema(path);
const fieldValue = obj[key];
if (path === 'generationProfiles' && typeof fieldValue === 'string') {
validateType(
fieldValue,
{ type: 'string', optional: false, enumValues: ['quick', 'standard', 'thorough'] },
currentPath,
key,
);
} else if (childSchema && fieldValue !== null && typeof fieldValue === 'object') {
if (childSchema && fieldValue !== null && typeof fieldValue === 'object') {
// Validate the dynamic container value against its specific schema
validateConfigAgainstSchema(fieldValue, childSchema, currentPath);
} else if (childSchema) {
@@ -633,19 +615,6 @@ export function validateConfigSemantics(config: Config): void {
);
}
}
if (preset.depth !== undefined) {
const validDepths = ['quick', 'standard', 'deep'];
const depthValue = preset.depth;
if (typeof depthValue === 'string' && !validDepths.includes(depthValue as string)) {
throw new ConfigValidationError(
`Preset "${presetName}" has invalid depth: "${depthValue}"`,
`presets.${presetName}.depth`,
'depth',
depthValue,
`Must be one of: ${validDepths.join(', ')}.`,
);
}
}
}
}
-5
View File
@@ -101,10 +101,6 @@ export function createContext(options: Record<string, unknown> = {}): CliContext
? options.color
: 'auto';
const generationProfile = typeof options.generationProfile === 'string'
? options.generationProfile
: undefined;
return {
cwd,
env: {
@@ -119,7 +115,6 @@ export function createContext(options: Record<string, unknown> = {}): CliContext
options: {
config: typeof options.config === 'string' ? options.config : undefined,
profile: typeof options.profile === 'string' ? options.profile : undefined,
generationProfile,
format,
color,
quiet: options.quiet === true,
-51
View File
@@ -1,51 +0,0 @@
import type { Config } from './config-loader.js'
export type ResolvedGenerationProfile = 'quick' | 'standard' | 'thorough'
export class GenerationProfileResolutionError extends Error {
constructor(message: string) {
super(message)
this.name = 'GenerationProfileResolutionError'
}
}
function isBuiltInProfile(value: string): value is ResolvedGenerationProfile {
return value === 'quick' || value === 'standard' || value === 'thorough'
}
export function resolveGenerationProfileOverride(
rawProfile: string | undefined,
config: Config,
): ResolvedGenerationProfile | undefined {
if (!rawProfile) {
return undefined
}
if (isBuiltInProfile(rawProfile)) {
return rawProfile
}
const aliases = config.generationProfiles
if (!aliases) {
throw new GenerationProfileResolutionError(
`Unknown generation profile "${rawProfile}". Use one of: quick, standard, thorough, or define an alias in config.generationProfiles.`,
)
}
const alias = aliases[rawProfile]
if (!alias) {
const available = Object.keys(aliases).join(', ') || 'none'
throw new GenerationProfileResolutionError(
`Unknown generation profile "${rawProfile}". Built-ins: quick, standard, thorough. Config aliases: ${available}.`,
)
}
const target = typeof alias === 'string' ? alias : alias.base
if (!isBuiltInProfile(target)) {
throw new GenerationProfileResolutionError(
`Invalid generation profile alias "${rawProfile}". Alias must resolve to quick, standard, or thorough.`,
)
}
return target
}
+2 -9
View File
@@ -22,7 +22,6 @@ const HELP_HEADER = `
${pc.dim('Global Options:')}
--config <path> Config file path
--profile <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--cwd <path> Working directory override
--format <mode> Output format: human | json | ndjson (default: human)
--color <mode> Color mode: auto | always | never (default: auto)
@@ -71,7 +70,6 @@ function getCommandHelp(command: string): string {
${pc.dim('Options:')}
--profile <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--routes <filter> Route filter pattern
--seed <number> Deterministic seed
--changed Filter to git-modified routes
@@ -103,7 +101,6 @@ function getCommandHelp(command: string): string {
${pc.dim('Options:')}
--profile <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--seed <number> Deterministic seed
${pc.dim('Examples:')}
@@ -225,7 +222,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
// Global flags
cli.option('--config <path>', 'Config file path');
cli.option('--profile <name>', 'Profile name from config');
cli.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cli.option('--cwd <path>', 'Working directory override');
cli.option('--format <mode>', 'Output format: human | json | ndjson', { default: 'human' });
cli.option('--color <mode>', 'Color mode: auto | always | never', { default: 'auto' });
@@ -270,7 +266,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
break;
case 'verify':
cmd.option('--profile <name>', 'Profile name from config');
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cmd.option('--routes <filter>', 'Route filter pattern');
cmd.option('--seed <number>', 'Deterministic seed');
cmd.option('--changed', 'Filter to git-modified routes');
@@ -281,7 +276,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
break;
case 'qualify':
cmd.option('--profile <name>', 'Profile name from config');
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cmd.option('--seed <number>', 'Deterministic seed');
break;
case 'replay':
@@ -373,16 +367,15 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
// Handle unknown flags
const knownGlobalFlags = new Set([
'--config', '--profile', '--cwd', '--format', '--color',
'--generation-profile',
'--quiet', '--verbose', '--artifact-dir', '--workspace',
'-v', '--version', '-h', '--help',
]);
const commandSpecificFlags: Record<string, Set<string>> = {
init: new Set(['--preset', '--force', '--noninteractive']),
verify: new Set(['--profile', '--generation-profile', '--routes', '--seed', '--changed', '--workspace']),
verify: new Set(['--profile', '--routes', '--seed', '--changed', '--workspace']),
observe: new Set(['--profile', '--check-config', '--workspace']),
qualify: new Set(['--profile', '--generation-profile', '--seed', '--workspace']),
qualify: new Set(['--profile', '--seed', '--workspace']),
replay: new Set(['--artifact']),
doctor: new Set(['--mode', '--strict', '--workspace']),
migrate: new Set(['--check', '--dry-run', '--write']),
-3
View File
@@ -29,7 +29,6 @@ export interface CliContext {
options: {
config: string | undefined;
profile: string | undefined;
generationProfile?: string;
format: OutputFormat;
color: ColorMode;
quiet: boolean;
@@ -132,7 +131,6 @@ export interface ProfileDefinition {
* required: ["name"],
* properties: {
* name: { type: "string" },
* depth: { type: "string", enum: ["quick", "standard", "deep"] },
* timeout: { type: "number" },
* parallel: { type: "boolean" },
* chaos: { type: "boolean" },
@@ -143,7 +141,6 @@ export interface ProfileDefinition {
*/
export interface PresetDefinition {
name: string;
depth?: "quick" | "standard" | "deep";
timeout?: number;
parallel?: boolean;
chaos?: boolean;
-23
View File
@@ -183,29 +183,6 @@ function generateNextSteps(failure: FailureRecord): string {
// Progress and summary rendering
// ---------------------------------------------------------------------------
/**
* Render progress for a running command.
* Safe for CI (no spinners, just text updates).
*/
export function renderProgress(
current: number,
total: number,
label: string,
ctx: OutputContext,
): string {
const c = getColorizer(ctx);
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
if (ctx.isCI || !ctx.isTTY) {
// CI mode: simple text, no spinner
return `${label} [${current}/${total}] ${pct}%`;
}
// TTY mode: with color
const bar = renderProgressBar(current, total, 20, ctx);
return `${c.dim(label)} ${bar} ${c.bold(`${pct}%`)}`;
}
/**
* Render a simple ASCII progress bar.
*/