feat: Phase 1 - Complete TDD Workflow Automation System (#1289)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-10-14 20:25:01 +02:00
parent c92cee72c7
commit 11ace9da1f
83 changed files with 17215 additions and 2342 deletions

View File

@@ -14,7 +14,7 @@ import { ContextCommand } from './commands/context.command.js';
import { StartCommand } from './commands/start.command.js';
import { SetStatusCommand } from './commands/set-status.command.js';
import { ExportCommand } from './commands/export.command.js';
import { AutopilotCommand } from './commands/autopilot.command.js';
import { AutopilotCommand } from './commands/autopilot/index.js';
/**
* Command metadata for registration
@@ -73,7 +73,8 @@ export class CommandRegistry {
},
{
name: 'autopilot',
description: 'Execute a task autonomously using TDD workflow',
description:
'AI agent orchestration for TDD workflow (start, resume, next, complete, commit, status, abort)',
commandClass: AutopilotCommand as any,
category: 'development'
},

View File

@@ -0,0 +1,119 @@
/**
* @fileoverview Abort Command - Safely terminate workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
deleteWorkflowState,
OutputFormatter
} from './shared.js';
import inquirer from 'inquirer';
interface AbortOptions extends AutopilotBaseOptions {
force?: boolean;
}
/**
* Abort Command - Safely terminate workflow and clean up state
*/
export class AbortCommand extends Command {
constructor() {
super('abort');
this.description('Abort the current TDD workflow and clean up state')
.option('-f, --force', 'Force abort without confirmation')
.action(async (options: AbortOptions) => {
await this.execute(options);
});
}
private async execute(options: AbortOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: AbortOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.warning('No active workflow to abort');
return;
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
// Get progress before abort
const progress = orchestrator.getProgress();
const currentSubtask = orchestrator.getCurrentSubtask();
// Confirm abort if not forced or in JSON mode
if (!mergedOptions.force && !mergedOptions.json) {
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message:
`This will abort the workflow for task ${state.context.taskId}. ` +
`Progress: ${progress.completed}/${progress.total} subtasks completed. ` +
`Continue?`,
default: false
}
]);
if (!confirmed) {
formatter.info('Abort cancelled');
return;
}
}
// Trigger abort in orchestrator
orchestrator.transition({ type: 'ABORT' });
// Delete workflow state
await deleteWorkflowState(mergedOptions.projectRoot!);
// Output result
formatter.success('Workflow aborted', {
taskId: state.context.taskId,
branchName: state.context.branchName,
progress: {
completed: progress.completed,
total: progress.total
},
lastSubtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title
}
: null,
note: 'Branch and commits remain. Clean up manually if needed.'
});
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,169 @@
/**
* @fileoverview Commit Command - Create commit with enhanced message generation
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
createGitAdapter,
createCommitMessageGenerator,
OutputFormatter,
saveWorkflowState
} from './shared.js';
type CommitOptions = AutopilotBaseOptions;
/**
* Commit Command - Create commit using enhanced message generator
*/
export class CommitCommand extends Command {
constructor() {
super('commit');
this.description('Create a commit for the completed GREEN phase').action(
async (options: CommitOptions) => {
await this.execute(options);
}
);
}
private async execute(options: CommitOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: CommitOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
orchestrator.enableAutoPersist(async (newState) => {
await saveWorkflowState(mergedOptions.projectRoot!, newState);
});
// Verify in COMMIT phase
const tddPhase = orchestrator.getCurrentTDDPhase();
if (tddPhase !== 'COMMIT') {
formatter.error('Not in COMMIT phase', {
currentPhase: tddPhase || orchestrator.getCurrentPhase(),
suggestion: 'Complete RED and GREEN phases first'
});
process.exit(1);
}
// Get current subtask
const currentSubtask = orchestrator.getCurrentSubtask();
if (!currentSubtask) {
formatter.error('No current subtask');
process.exit(1);
}
// Initialize git adapter
const gitAdapter = createGitAdapter(mergedOptions.projectRoot!);
await gitAdapter.ensureGitRepository();
// Check for staged changes
const hasStagedChanges = await gitAdapter.hasStagedChanges();
if (!hasStagedChanges) {
// Stage all changes
formatter.info('No staged changes, staging all changes...');
await gitAdapter.stageFiles(['.']);
}
// Get changed files for scope detection
const status = await gitAdapter.getStatus();
const changedFiles = [...status.staged, ...status.modified];
// Generate commit message
const messageGenerator = createCommitMessageGenerator();
const testResults = state.context.lastTestResults;
const commitMessage = messageGenerator.generateMessage({
type: 'feat',
description: currentSubtask.title,
changedFiles,
taskId: state.context.taskId,
phase: 'TDD',
tag: (state.context.metadata.tag as string) || undefined,
testsPassing: testResults?.passed,
testsFailing: testResults?.failed,
coveragePercent: undefined // Could be added if available
});
// Create commit with metadata
await gitAdapter.createCommit(commitMessage, {
metadata: {
taskId: state.context.taskId,
subtaskId: currentSubtask.id,
phase: 'COMMIT',
tddCycle: 'complete'
}
});
// Get commit info
const lastCommit = await gitAdapter.getLastCommit();
// Complete COMMIT phase (this marks subtask as completed)
orchestrator.transition({ type: 'COMMIT_COMPLETE' });
// Check if should advance to next subtask
const progress = orchestrator.getProgress();
if (progress.current < progress.total) {
orchestrator.transition({ type: 'SUBTASK_COMPLETE' });
} else {
// All subtasks complete
orchestrator.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
}
// Output success
formatter.success('Commit created', {
commitHash: lastCommit.hash.substring(0, 7),
message: commitMessage.split('\n')[0], // First line only
subtask: {
id: currentSubtask.id,
title: currentSubtask.title,
status: currentSubtask.status
},
progress: {
completed: progress.completed,
total: progress.total,
percentage: progress.percentage
},
nextAction:
progress.completed < progress.total
? 'Start next subtask with RED phase'
: 'All subtasks complete. Run: autopilot status'
});
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,172 @@
/**
* @fileoverview Complete Command - Complete current TDD phase with validation
*/
import { Command } from 'commander';
import { WorkflowOrchestrator, TestResult } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
interface CompleteOptions extends AutopilotBaseOptions {
results?: string;
coverage?: string;
}
/**
* Complete Command - Mark current phase as complete with validation
*/
export class CompleteCommand extends Command {
constructor() {
super('complete');
this.description('Complete the current TDD phase with result validation')
.option(
'-r, --results <json>',
'Test results JSON (with total, passed, failed, skipped)'
)
.option('-c, --coverage <percent>', 'Coverage percentage')
.action(async (options: CompleteOptions) => {
await this.execute(options);
});
}
private async execute(options: CompleteOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: CompleteOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator with persistence
const { saveWorkflowState } = await import('./shared.js');
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
orchestrator.enableAutoPersist(async (newState) => {
await saveWorkflowState(mergedOptions.projectRoot!, newState);
});
// Get current phase
const tddPhase = orchestrator.getCurrentTDDPhase();
const currentSubtask = orchestrator.getCurrentSubtask();
if (!tddPhase) {
formatter.error('Not in a TDD phase', {
phase: orchestrator.getCurrentPhase()
});
process.exit(1);
}
// Validate based on phase
if (tddPhase === 'RED' || tddPhase === 'GREEN') {
if (!mergedOptions.results) {
formatter.error('Test results required for RED/GREEN phase', {
usage:
'--results \'{"total":10,"passed":9,"failed":1,"skipped":0}\''
});
process.exit(1);
}
// Parse test results
let testResults: TestResult;
try {
const parsed = JSON.parse(mergedOptions.results);
testResults = {
total: parsed.total || 0,
passed: parsed.passed || 0,
failed: parsed.failed || 0,
skipped: parsed.skipped || 0,
phase: tddPhase
};
} catch (error) {
formatter.error('Invalid test results JSON', {
error: (error as Error).message
});
process.exit(1);
}
// Validate RED phase requirements
if (tddPhase === 'RED' && testResults.failed === 0) {
formatter.error('RED phase validation failed', {
reason: 'At least one test must be failing',
actual: {
passed: testResults.passed,
failed: testResults.failed
}
});
process.exit(1);
}
// Validate GREEN phase requirements
if (tddPhase === 'GREEN' && testResults.failed !== 0) {
formatter.error('GREEN phase validation failed', {
reason: 'All tests must pass',
actual: {
passed: testResults.passed,
failed: testResults.failed
}
});
process.exit(1);
}
// Complete phase with test results
if (tddPhase === 'RED') {
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults
});
formatter.success('RED phase completed', {
nextPhase: 'GREEN',
testResults,
subtask: currentSubtask?.title
});
} else {
orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults
});
formatter.success('GREEN phase completed', {
nextPhase: 'COMMIT',
testResults,
subtask: currentSubtask?.title,
suggestion: 'Run: autopilot commit'
});
}
} else if (tddPhase === 'COMMIT') {
formatter.error('Use "autopilot commit" to complete COMMIT phase');
process.exit(1);
}
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,82 @@
/**
* @fileoverview Autopilot CLI Commands for AI Agent Orchestration
* Provides subcommands for starting, resuming, and advancing the TDD workflow
* with JSON output for machine parsing.
*/
import { Command } from 'commander';
import { StartCommand } from './start.command.js';
import { ResumeCommand } from './resume.command.js';
import { NextCommand } from './next.command.js';
import { CompleteCommand } from './complete.command.js';
import { CommitCommand } from './commit.command.js';
import { StatusCommand } from './status.command.js';
import { AbortCommand } from './abort.command.js';
/**
* Shared command options for all autopilot commands
*/
export interface AutopilotBaseOptions {
json?: boolean;
verbose?: boolean;
projectRoot?: string;
}
/**
* AutopilotCommand with subcommands for TDD workflow orchestration
*/
export class AutopilotCommand extends Command {
constructor() {
super('autopilot');
// Configure main command
this.description('AI agent orchestration for TDD workflow execution')
.alias('ap')
// Global options for all subcommands
.option('--json', 'Output in JSON format for machine parsing')
.option('-v, --verbose', 'Enable verbose output')
.option(
'-p, --project-root <path>',
'Project root directory',
process.cwd()
);
// Register subcommands
this.registerSubcommands();
}
/**
* Register all autopilot subcommands
*/
private registerSubcommands(): void {
// Start new TDD workflow
this.addCommand(new StartCommand());
// Resume existing workflow
this.addCommand(new ResumeCommand());
// Get next action
this.addCommand(new NextCommand());
// Complete current phase
this.addCommand(new CompleteCommand());
// Create commit
this.addCommand(new CommitCommand());
// Show status
this.addCommand(new StatusCommand());
// Abort workflow
this.addCommand(new AbortCommand());
}
/**
* Register this command on an existing program
*/
static register(program: Command): AutopilotCommand {
const autopilotCommand = new AutopilotCommand();
program.addCommand(autopilotCommand);
return autopilotCommand;
}
}

View File

@@ -0,0 +1,164 @@
/**
* @fileoverview Next Command - Get next action in TDD workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
type NextOptions = AutopilotBaseOptions;
/**
* Next Command - Get next action details
*/
export class NextCommand extends Command {
constructor() {
super('next');
this.description(
'Get the next action to perform in the TDD workflow'
).action(async (options: NextOptions) => {
await this.execute(options);
});
}
private async execute(options: NextOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: NextOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
// Get current phase and subtask
const phase = orchestrator.getCurrentPhase();
const tddPhase = orchestrator.getCurrentTDDPhase();
const currentSubtask = orchestrator.getCurrentSubtask();
// Determine next action based on phase
let actionType: string;
let actionDescription: string;
let actionDetails: Record<string, unknown> = {};
if (phase === 'COMPLETE') {
formatter.success('Workflow complete', {
message: 'All subtasks have been completed',
taskId: state.context.taskId
});
return;
}
if (phase === 'SUBTASK_LOOP' && tddPhase) {
switch (tddPhase) {
case 'RED':
actionType = 'generate_test';
actionDescription = 'Write failing test for current subtask';
actionDetails = {
subtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null,
testCommand: 'npm test', // Could be customized based on config
expectedOutcome: 'Test should fail'
};
break;
case 'GREEN':
actionType = 'implement_code';
actionDescription = 'Implement code to pass the failing test';
actionDetails = {
subtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null,
testCommand: 'npm test',
expectedOutcome: 'All tests should pass',
lastTestResults: state.context.lastTestResults
};
break;
case 'COMMIT':
actionType = 'commit_changes';
actionDescription = 'Commit the changes';
actionDetails = {
subtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null,
suggestion: 'Use: autopilot commit'
};
break;
default:
actionType = 'unknown';
actionDescription = 'Unknown TDD phase';
}
} else {
actionType = 'workflow_phase';
actionDescription = `Currently in ${phase} phase`;
}
// Output next action
const output = {
action: actionType,
description: actionDescription,
phase,
tddPhase,
taskId: state.context.taskId,
branchName: state.context.branchName,
...actionDetails
};
if (mergedOptions.json) {
formatter.output(output);
} else {
formatter.success('Next action', output);
}
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,111 @@
/**
* @fileoverview Resume Command - Restore and resume TDD workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
type ResumeOptions = AutopilotBaseOptions;
/**
* Resume Command - Restore workflow from saved state
*/
export class ResumeCommand extends Command {
constructor() {
super('resume');
this.description('Resume a previously started TDD workflow').action(
async (options: ResumeOptions) => {
await this.execute(options);
}
);
}
private async execute(options: ResumeOptions): Promise<void> {
// Inherit parent options (autopilot command)
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: ResumeOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No workflow state found', {
suggestion: 'Start a new workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
formatter.info('Loading workflow state...');
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Validate state can be resumed
const orchestrator = new WorkflowOrchestrator(state.context);
if (!orchestrator.canResumeFromState(state)) {
formatter.error('Invalid workflow state', {
suggestion:
'State file may be corrupted. Consider starting a new workflow.'
});
process.exit(1);
}
// Restore state
orchestrator.restoreState(state);
// Re-enable auto-persistence
const { saveWorkflowState } = await import('./shared.js');
orchestrator.enableAutoPersist(async (newState) => {
await saveWorkflowState(mergedOptions.projectRoot!, newState);
});
// Get progress
const progress = orchestrator.getProgress();
const currentSubtask = orchestrator.getCurrentSubtask();
// Output success
formatter.success('Workflow resumed', {
taskId: state.context.taskId,
phase: orchestrator.getCurrentPhase(),
tddPhase: orchestrator.getCurrentTDDPhase(),
branchName: state.context.branchName,
progress: {
completed: progress.completed,
total: progress.total,
percentage: progress.percentage
},
currentSubtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null
});
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,262 @@
/**
* @fileoverview Shared utilities for autopilot commands
*/
import {
WorkflowOrchestrator,
WorkflowStateManager,
GitAdapter,
CommitMessageGenerator
} from '@tm/core';
import type { WorkflowState, WorkflowContext, SubtaskInfo } from '@tm/core';
import chalk from 'chalk';
/**
* Base options interface for all autopilot commands
*/
export interface AutopilotBaseOptions {
projectRoot?: string;
json?: boolean;
verbose?: boolean;
}
/**
* Load workflow state from disk using WorkflowStateManager
*/
export async function loadWorkflowState(
projectRoot: string
): Promise<WorkflowState | null> {
const stateManager = new WorkflowStateManager(projectRoot);
if (!(await stateManager.exists())) {
return null;
}
try {
return await stateManager.load();
} catch (error) {
throw new Error(
`Failed to load workflow state: ${(error as Error).message}`
);
}
}
/**
* Save workflow state to disk using WorkflowStateManager
*/
export async function saveWorkflowState(
projectRoot: string,
state: WorkflowState
): Promise<void> {
const stateManager = new WorkflowStateManager(projectRoot);
try {
await stateManager.save(state);
} catch (error) {
throw new Error(
`Failed to save workflow state: ${(error as Error).message}`
);
}
}
/**
* Delete workflow state from disk using WorkflowStateManager
*/
export async function deleteWorkflowState(projectRoot: string): Promise<void> {
const stateManager = new WorkflowStateManager(projectRoot);
await stateManager.delete();
}
/**
* Check if workflow state exists using WorkflowStateManager
*/
export async function hasWorkflowState(projectRoot: string): Promise<boolean> {
const stateManager = new WorkflowStateManager(projectRoot);
return await stateManager.exists();
}
/**
* Initialize WorkflowOrchestrator with persistence
*/
export function createOrchestrator(
context: WorkflowContext,
projectRoot: string
): WorkflowOrchestrator {
const orchestrator = new WorkflowOrchestrator(context);
const stateManager = new WorkflowStateManager(projectRoot);
// Enable auto-persistence
orchestrator.enableAutoPersist(async (state: WorkflowState) => {
await stateManager.save(state);
});
return orchestrator;
}
/**
* Initialize GitAdapter for project
*/
export function createGitAdapter(projectRoot: string): GitAdapter {
return new GitAdapter(projectRoot);
}
/**
* Initialize CommitMessageGenerator
*/
export function createCommitMessageGenerator(): CommitMessageGenerator {
return new CommitMessageGenerator();
}
/**
* Output formatter for JSON and text modes
*/
export class OutputFormatter {
constructor(private useJson: boolean) {}
/**
* Output data in appropriate format
*/
output(data: Record<string, unknown>): void {
if (this.useJson) {
console.log(JSON.stringify(data, null, 2));
} else {
this.outputText(data);
}
}
/**
* Output data in human-readable text format
*/
private outputText(data: Record<string, unknown>): void {
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'object' && value !== null) {
console.log(chalk.cyan(`${key}:`));
this.outputObject(value as Record<string, unknown>, ' ');
} else {
console.log(chalk.white(`${key}: ${value}`));
}
}
}
/**
* Output nested object with indentation
*/
private outputObject(obj: Record<string, unknown>, indent: string): void {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null) {
console.log(chalk.cyan(`${indent}${key}:`));
this.outputObject(value as Record<string, unknown>, indent + ' ');
} else {
console.log(chalk.gray(`${indent}${key}: ${value}`));
}
}
}
/**
* Output error message
*/
error(message: string, details?: Record<string, unknown>): void {
if (this.useJson) {
console.error(
JSON.stringify(
{
error: message,
...details
},
null,
2
)
);
} else {
console.error(chalk.red(`Error: ${message}`));
if (details) {
for (const [key, value] of Object.entries(details)) {
console.error(chalk.gray(` ${key}: ${value}`));
}
}
}
}
/**
* Output success message
*/
success(message: string, data?: Record<string, unknown>): void {
if (this.useJson) {
console.log(
JSON.stringify(
{
success: true,
message,
...data
},
null,
2
)
);
} else {
console.log(chalk.green(`${message}`));
if (data) {
this.output(data);
}
}
}
/**
* Output warning message
*/
warning(message: string): void {
if (this.useJson) {
console.warn(
JSON.stringify(
{
warning: message
},
null,
2
)
);
} else {
console.warn(chalk.yellow(`${message}`));
}
}
/**
* Output info message
*/
info(message: string): void {
if (this.useJson) {
// Don't output info messages in JSON mode
return;
}
console.log(chalk.blue(` ${message}`));
}
}
/**
* Validate task ID format
*/
export function validateTaskId(taskId: string): boolean {
// Task ID should be in format: number or number.number (e.g., "1" or "1.2")
const pattern = /^\d+(\.\d+)*$/;
return pattern.test(taskId);
}
/**
* Parse subtasks from task data
*/
export function parseSubtasks(
task: any,
maxAttempts: number = 3
): SubtaskInfo[] {
if (!task.subtasks || !Array.isArray(task.subtasks)) {
return [];
}
return task.subtasks.map((subtask: any) => ({
id: subtask.id,
title: subtask.title,
status: subtask.status === 'done' ? 'completed' : 'pending',
attempts: 0,
maxAttempts
}));
}

View File

@@ -0,0 +1,168 @@
/**
* @fileoverview Start Command - Initialize and start TDD workflow
*/
import { Command } from 'commander';
import { createTaskMasterCore, type WorkflowContext } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
createOrchestrator,
createGitAdapter,
OutputFormatter,
validateTaskId,
parseSubtasks
} from './shared.js';
interface StartOptions extends AutopilotBaseOptions {
force?: boolean;
maxAttempts?: string;
}
/**
* Start Command - Initialize new TDD workflow
*/
export class StartCommand extends Command {
constructor() {
super('start');
this.description('Initialize and start a new TDD workflow for a task')
.argument('<taskId>', 'Task ID to start workflow for')
.option('-f, --force', 'Force start even if workflow state exists')
.option('--max-attempts <number>', 'Maximum attempts per subtask', '3')
.action(async (taskId: string, options: StartOptions) => {
await this.execute(taskId, options);
});
}
private async execute(taskId: string, options: StartOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: StartOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Validate task ID
if (!validateTaskId(taskId)) {
formatter.error('Invalid task ID format', {
taskId,
expected: 'Format: number or number.number (e.g., "1" or "1.2")'
});
process.exit(1);
}
// Check for existing workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (hasState && !mergedOptions.force) {
formatter.error(
'Workflow state already exists. Use --force to overwrite or resume with "autopilot resume"'
);
process.exit(1);
}
// Initialize Task Master Core
const tmCore = await createTaskMasterCore({
projectPath: mergedOptions.projectRoot!
});
// Get current tag from ConfigManager
const currentTag = tmCore.getActiveTag();
// Load task
formatter.info(`Loading task ${taskId}...`);
const { task } = await tmCore.getTaskWithSubtask(taskId);
if (!task) {
formatter.error('Task not found', { taskId });
await tmCore.close();
process.exit(1);
}
// Validate task has subtasks
if (!task.subtasks || task.subtasks.length === 0) {
formatter.error('Task has no subtasks. Expand task first.', {
taskId,
suggestion: `Run: task-master expand --id=${taskId}`
});
await tmCore.close();
process.exit(1);
}
// Initialize Git adapter
const gitAdapter = createGitAdapter(mergedOptions.projectRoot!);
await gitAdapter.ensureGitRepository();
await gitAdapter.ensureCleanWorkingTree();
// Parse subtasks
const maxAttempts = parseInt(mergedOptions.maxAttempts || '3', 10);
const subtasks = parseSubtasks(task, maxAttempts);
// Create workflow context
const context: WorkflowContext = {
taskId: task.id,
subtasks,
currentSubtaskIndex: 0,
errors: [],
metadata: {
startedAt: new Date().toISOString(),
tags: task.tags || []
}
};
// Create orchestrator with persistence
const orchestrator = createOrchestrator(
context,
mergedOptions.projectRoot!
);
// Complete PREFLIGHT phase
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
// Generate descriptive branch name
const sanitizedTitle = task.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50);
const formattedTaskId = taskId.replace(/\./g, '-');
const tagPrefix = currentTag ? `${currentTag}/` : '';
const branchName = `${tagPrefix}task-${formattedTaskId}-${sanitizedTitle}`;
// Create and checkout branch
formatter.info(`Creating branch: ${branchName}`);
await gitAdapter.createAndCheckoutBranch(branchName);
// Transition to SUBTASK_LOOP
orchestrator.transition({
type: 'BRANCH_CREATED',
branchName
});
// Output success
formatter.success('TDD workflow started', {
taskId: task.id,
title: task.title,
phase: orchestrator.getCurrentPhase(),
tddPhase: orchestrator.getCurrentTDDPhase(),
branchName,
subtasks: subtasks.length,
currentSubtask: subtasks[0]?.title
});
// Clean up
await tmCore.close();
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,114 @@
/**
* @fileoverview Status Command - Show workflow progress
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
type StatusOptions = AutopilotBaseOptions;
/**
* Status Command - Show current workflow status
*/
export class StatusCommand extends Command {
constructor() {
super('status');
this.description('Show current TDD workflow status and progress').action(
async (options: StatusOptions) => {
await this.execute(options);
}
);
}
private async execute(options: StatusOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: StatusOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
// Get status information
const phase = orchestrator.getCurrentPhase();
const tddPhase = orchestrator.getCurrentTDDPhase();
const progress = orchestrator.getProgress();
const currentSubtask = orchestrator.getCurrentSubtask();
const errors = state.context.errors ?? [];
// Build status output
const status = {
taskId: state.context.taskId,
phase,
tddPhase,
branchName: state.context.branchName,
progress: {
completed: progress.completed,
total: progress.total,
current: progress.current,
percentage: progress.percentage
},
currentSubtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
status: currentSubtask.status,
attempts: currentSubtask.attempts,
maxAttempts: currentSubtask.maxAttempts
}
: null,
subtasks: state.context.subtasks.map((st) => ({
id: st.id,
title: st.title,
status: st.status,
attempts: st.attempts
})),
errors: errors.length > 0 ? errors : undefined,
metadata: state.context.metadata
};
if (mergedOptions.json) {
formatter.output(status);
} else {
formatter.success('Workflow status', status);
}
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,540 @@
/**
* @fileoverview Integration tests for autopilot workflow commands
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { WorkflowState } from '@tm/core';
// Track file system state in memory - must be in vi.hoisted() for mock access
const {
mockFileSystem,
pathExistsFn,
readJSONFn,
writeJSONFn,
ensureDirFn,
removeFn
} = vi.hoisted(() => {
const mockFileSystem = new Map<string, string>();
return {
mockFileSystem,
pathExistsFn: vi.fn((path: string) =>
Promise.resolve(mockFileSystem.has(path))
),
readJSONFn: vi.fn((path: string) => {
const data = mockFileSystem.get(path);
return data
? Promise.resolve(JSON.parse(data))
: Promise.reject(new Error('File not found'));
}),
writeJSONFn: vi.fn((path: string, data: any) => {
mockFileSystem.set(path, JSON.stringify(data));
return Promise.resolve();
}),
ensureDirFn: vi.fn(() => Promise.resolve()),
removeFn: vi.fn((path: string) => {
mockFileSystem.delete(path);
return Promise.resolve();
})
};
});
// Mock fs-extra before any imports
vi.mock('fs-extra', () => ({
default: {
pathExists: pathExistsFn,
readJSON: readJSONFn,
writeJSON: writeJSONFn,
ensureDir: ensureDirFn,
remove: removeFn
}
}));
vi.mock('@tm/core', () => ({
WorkflowOrchestrator: vi.fn().mockImplementation((context) => ({
getCurrentPhase: vi.fn().mockReturnValue('SUBTASK_LOOP'),
getCurrentTDDPhase: vi.fn().mockReturnValue('RED'),
getContext: vi.fn().mockReturnValue(context),
transition: vi.fn(),
restoreState: vi.fn(),
getState: vi.fn().mockReturnValue({ phase: 'SUBTASK_LOOP', context }),
enableAutoPersist: vi.fn(),
canResumeFromState: vi.fn().mockReturnValue(true),
getCurrentSubtask: vi.fn().mockReturnValue({
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}),
getProgress: vi.fn().mockReturnValue({
completed: 0,
total: 3,
current: 1,
percentage: 0
}),
canProceed: vi.fn().mockReturnValue(false)
})),
GitAdapter: vi.fn().mockImplementation(() => ({
ensureGitRepository: vi.fn().mockResolvedValue(undefined),
ensureCleanWorkingTree: vi.fn().mockResolvedValue(undefined),
createAndCheckoutBranch: vi.fn().mockResolvedValue(undefined),
hasStagedChanges: vi.fn().mockResolvedValue(true),
getStatus: vi.fn().mockResolvedValue({
staged: ['file1.ts'],
modified: ['file2.ts']
}),
createCommit: vi.fn().mockResolvedValue(undefined),
getLastCommit: vi.fn().mockResolvedValue({
hash: 'abc123def456',
message: 'test commit'
}),
stageFiles: vi.fn().mockResolvedValue(undefined)
})),
CommitMessageGenerator: vi.fn().mockImplementation(() => ({
generateMessage: vi.fn().mockReturnValue('feat: test commit message')
})),
createTaskMasterCore: vi.fn().mockResolvedValue({
getTaskWithSubtask: vi.fn().mockResolvedValue({
task: {
id: '1',
title: 'Test Task',
subtasks: [
{ id: '1', title: 'Subtask 1', status: 'pending' },
{ id: '2', title: 'Subtask 2', status: 'pending' },
{ id: '3', title: 'Subtask 3', status: 'pending' }
],
tag: 'test'
}
}),
close: vi.fn().mockResolvedValue(undefined)
})
}));
// Import after mocks are set up
import { Command } from 'commander';
import { AutopilotCommand } from '../../../../src/commands/autopilot/index.js';
describe('Autopilot Workflow Integration Tests', () => {
const projectRoot = '/test/project';
let program: Command;
beforeEach(() => {
mockFileSystem.clear();
// Clear mock call history
pathExistsFn.mockClear();
readJSONFn.mockClear();
writeJSONFn.mockClear();
ensureDirFn.mockClear();
removeFn.mockClear();
program = new Command();
AutopilotCommand.register(program);
// Use exitOverride to handle Commander exits in tests
program.exitOverride();
});
afterEach(() => {
mockFileSystem.clear();
vi.restoreAllMocks();
});
describe('start command', () => {
it('should initialize workflow and create branch', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'start',
'1',
'--project-root',
projectRoot,
'--json'
]);
// Verify writeJSON was called with state
expect(writeJSONFn).toHaveBeenCalledWith(
expect.stringContaining('workflow-state.json'),
expect.objectContaining({
phase: expect.any(String),
context: expect.any(Object)
}),
expect.any(Object)
);
consoleLogSpy.mockRestore();
});
it('should reject invalid task ID', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'start',
'invalid',
'--project-root',
projectRoot,
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
it('should reject starting when workflow exists without force', async () => {
// Create existing state
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [],
currentSubtaskIndex: 0,
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'start',
'1',
'--project-root',
projectRoot,
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
});
describe('resume command', () => {
beforeEach(() => {
// Create saved state
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should restore workflow from saved state', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'resume',
'--project-root',
projectRoot,
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.success).toBe(true);
expect(output.taskId).toBe('1');
consoleLogSpy.mockRestore();
});
it('should error when no state exists', async () => {
mockFileSystem.clear();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'resume',
'--project-root',
projectRoot,
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
});
describe('next command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should return next action in JSON format', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'next',
'--project-root',
projectRoot,
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.action).toBe('generate_test');
expect(output.phase).toBe('SUBTASK_LOOP');
expect(output.tddPhase).toBe('RED');
consoleLogSpy.mockRestore();
});
});
describe('status command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{ id: '1', title: 'Subtask 1', status: 'completed', attempts: 1 },
{ id: '2', title: 'Subtask 2', status: 'pending', attempts: 0 },
{ id: '3', title: 'Subtask 3', status: 'pending', attempts: 0 }
],
currentSubtaskIndex: 1,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should display workflow progress', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'status',
'--project-root',
projectRoot,
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.taskId).toBe('1');
expect(output.phase).toBe('SUBTASK_LOOP');
expect(output.progress).toBeDefined();
expect(output.subtasks).toHaveLength(3);
consoleLogSpy.mockRestore();
});
});
describe('complete command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'in-progress',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should validate RED phase has failures', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'complete',
'--project-root',
projectRoot,
'--results',
'{"total":10,"passed":10,"failed":0,"skipped":0}',
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
it('should complete RED phase with failures', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'complete',
'--project-root',
projectRoot,
'--results',
'{"total":10,"passed":9,"failed":1,"skipped":0}',
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.success).toBe(true);
expect(output.nextPhase).toBe('GREEN');
consoleLogSpy.mockRestore();
});
});
describe('abort command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should abort workflow and delete state', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'abort',
'--project-root',
projectRoot,
'--force',
'--json'
]);
// Verify remove was called
expect(removeFn).toHaveBeenCalledWith(
expect.stringContaining('workflow-state.json')
);
consoleLogSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,202 @@
/**
* @fileoverview Unit tests for autopilot shared utilities
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
validateTaskId,
parseSubtasks,
OutputFormatter
} from '../../../../src/commands/autopilot/shared.js';
// Mock fs-extra
vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
readJSON: vi.fn(),
writeJSON: vi.fn(),
ensureDir: vi.fn(),
remove: vi.fn()
},
pathExists: vi.fn(),
readJSON: vi.fn(),
writeJSON: vi.fn(),
ensureDir: vi.fn(),
remove: vi.fn()
}));
describe('Autopilot Shared Utilities', () => {
const projectRoot = '/test/project';
const statePath = `${projectRoot}/.taskmaster/workflow-state.json`;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('validateTaskId', () => {
it('should validate simple task IDs', () => {
expect(validateTaskId('1')).toBe(true);
expect(validateTaskId('10')).toBe(true);
expect(validateTaskId('999')).toBe(true);
});
it('should validate subtask IDs', () => {
expect(validateTaskId('1.1')).toBe(true);
expect(validateTaskId('1.2')).toBe(true);
expect(validateTaskId('10.5')).toBe(true);
});
it('should validate nested subtask IDs', () => {
expect(validateTaskId('1.1.1')).toBe(true);
expect(validateTaskId('1.2.3')).toBe(true);
});
it('should reject invalid formats', () => {
expect(validateTaskId('')).toBe(false);
expect(validateTaskId('abc')).toBe(false);
expect(validateTaskId('1.')).toBe(false);
expect(validateTaskId('.1')).toBe(false);
expect(validateTaskId('1..2')).toBe(false);
expect(validateTaskId('1.2.3.')).toBe(false);
});
});
describe('parseSubtasks', () => {
it('should parse subtasks from task data', () => {
const task = {
id: '1',
title: 'Test Task',
subtasks: [
{ id: '1', title: 'Subtask 1', status: 'pending' },
{ id: '2', title: 'Subtask 2', status: 'done' },
{ id: '3', title: 'Subtask 3', status: 'in-progress' }
]
};
const result = parseSubtasks(task, 5);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({
id: '1',
title: 'Subtask 1',
status: 'pending',
attempts: 0,
maxAttempts: 5
});
expect(result[1]).toEqual({
id: '2',
title: 'Subtask 2',
status: 'completed',
attempts: 0,
maxAttempts: 5
});
});
it('should return empty array for missing subtasks', () => {
const task = { id: '1', title: 'Test Task' };
expect(parseSubtasks(task)).toEqual([]);
});
it('should use default maxAttempts', () => {
const task = {
subtasks: [{ id: '1', title: 'Subtask 1', status: 'pending' }]
};
const result = parseSubtasks(task);
expect(result[0].maxAttempts).toBe(3);
});
});
// State persistence tests omitted - covered in integration tests
describe('OutputFormatter', () => {
let consoleLogSpy: any;
let consoleErrorSpy: any;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('JSON mode', () => {
it('should output JSON for success', () => {
const formatter = new OutputFormatter(true);
formatter.success('Test message', { key: 'value' });
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.success).toBe(true);
expect(output.message).toBe('Test message');
expect(output.key).toBe('value');
});
it('should output JSON for error', () => {
const formatter = new OutputFormatter(true);
formatter.error('Error message', { code: 'ERR001' });
expect(consoleErrorSpy).toHaveBeenCalled();
const output = JSON.parse(consoleErrorSpy.mock.calls[0][0]);
expect(output.error).toBe('Error message');
expect(output.code).toBe('ERR001');
});
it('should output JSON for data', () => {
const formatter = new OutputFormatter(true);
formatter.output({ test: 'data' });
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.test).toBe('data');
});
});
describe('Text mode', () => {
it('should output formatted text for success', () => {
const formatter = new OutputFormatter(false);
formatter.success('Test message');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('✓ Test message')
);
});
it('should output formatted text for error', () => {
const formatter = new OutputFormatter(false);
formatter.error('Error message');
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Error: Error message')
);
});
it('should output formatted text for warning', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {});
const formatter = new OutputFormatter(false);
formatter.warning('Warning message');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠ Warning message')
);
consoleWarnSpy.mockRestore();
});
it('should not output info in JSON mode', () => {
const formatter = new OutputFormatter(true);
formatter.info('Info message');
expect(consoleLogSpy).not.toHaveBeenCalled();
});
});
});
});

25
apps/cli/vitest.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: [
'node_modules/',
'dist/',
'tests/',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.d.ts',
'**/mocks/**',
'**/fixtures/**',
'vitest.config.ts'
]
}
}
});