mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
Merge main into feature/mcp-server-support
Resolved conflicts: - apps/server/src/index.ts: merged MCP and Pipeline routes - apps/ui/src/lib/http-api-client.ts: merged MCP and Pipeline APIs - apps/ui/src/store/app-store.ts: merged type imports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,8 @@ import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
||||
import { createMCPRoutes } from './routes/mcp/index.js';
|
||||
import { MCPTestService } from './services/mcp-test-service.js';
|
||||
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -166,6 +168,7 @@ app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
21
apps/server/src/routes/pipeline/common.ts
Normal file
21
apps/server/src/routes/pipeline/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Common utilities for pipeline routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all pipeline endpoints.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for pipeline-related operations */
|
||||
export const logger = createLogger('Pipeline');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
77
apps/server/src/routes/pipeline/index.ts
Normal file
77
apps/server/src/routes/pipeline/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pipeline routes - HTTP API for pipeline configuration management
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Getting pipeline configuration
|
||||
* - Saving pipeline configuration
|
||||
* - Adding, updating, deleting, and reordering pipeline steps
|
||||
*
|
||||
* All endpoints use handler factories that receive the PipelineService instance.
|
||||
* Mounted at /api/pipeline in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { PipelineService } from '../../services/pipeline-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createGetConfigHandler } from './routes/get-config.js';
|
||||
import { createSaveConfigHandler } from './routes/save-config.js';
|
||||
import { createAddStepHandler } from './routes/add-step.js';
|
||||
import { createUpdateStepHandler } from './routes/update-step.js';
|
||||
import { createDeleteStepHandler } from './routes/delete-step.js';
|
||||
import { createReorderStepsHandler } from './routes/reorder-steps.js';
|
||||
|
||||
/**
|
||||
* Create pipeline router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /config - Get pipeline configuration
|
||||
* - POST /config/save - Save entire pipeline configuration
|
||||
* - POST /steps/add - Add a new pipeline step
|
||||
* - POST /steps/update - Update an existing pipeline step
|
||||
* - POST /steps/delete - Delete a pipeline step
|
||||
* - POST /steps/reorder - Reorder pipeline steps
|
||||
*
|
||||
* @param pipelineService - Instance of PipelineService for file I/O
|
||||
* @returns Express Router configured with all pipeline endpoints
|
||||
*/
|
||||
export function createPipelineRoutes(pipelineService: PipelineService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get pipeline configuration
|
||||
router.post(
|
||||
'/config',
|
||||
validatePathParams('projectPath'),
|
||||
createGetConfigHandler(pipelineService)
|
||||
);
|
||||
|
||||
// Save entire pipeline configuration
|
||||
router.post(
|
||||
'/config/save',
|
||||
validatePathParams('projectPath'),
|
||||
createSaveConfigHandler(pipelineService)
|
||||
);
|
||||
|
||||
// Pipeline step operations
|
||||
router.post(
|
||||
'/steps/add',
|
||||
validatePathParams('projectPath'),
|
||||
createAddStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/update',
|
||||
validatePathParams('projectPath'),
|
||||
createUpdateStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createDeleteStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/reorder',
|
||||
validatePathParams('projectPath'),
|
||||
createReorderStepsHandler(pipelineService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/add - Add a new pipeline step
|
||||
*
|
||||
* Adds a new step to the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, step: { name, order, instructions, colorClass } }
|
||||
* Response: { success: true, step: PipelineStep }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineStep } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAddStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, step } = req.body as {
|
||||
projectPath: string;
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!step) {
|
||||
res.status(400).json({ success: false, error: 'step is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!step.name) {
|
||||
res.status(400).json({ success: false, error: 'step.name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.instructions === undefined) {
|
||||
res.status(400).json({ success: false, error: 'step.instructions is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = await pipelineService.addStep(projectPath, step);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
step: newStep,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Add pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/delete - Delete a pipeline step
|
||||
*
|
||||
* Removes a step from the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepId: string }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepId } = req.body as {
|
||||
projectPath: string;
|
||||
stepId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepId) {
|
||||
res.status(400).json({ success: false, error: 'stepId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.deleteStep(projectPath, stepId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Delete pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* POST /api/pipeline/config - Get pipeline configuration
|
||||
*
|
||||
* Returns the pipeline configuration for a project.
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, config: PipelineConfig }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetConfigHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get pipeline config failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/reorder - Reorder pipeline steps
|
||||
*
|
||||
* Reorders the steps in the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepIds: string[] }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createReorderStepsHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepIds } = req.body as {
|
||||
projectPath: string;
|
||||
stepIds: string[];
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepIds || !Array.isArray(stepIds)) {
|
||||
res.status(400).json({ success: false, error: 'stepIds array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.reorderSteps(projectPath, stepIds);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Reorder pipeline steps failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/pipeline/config/save - Save entire pipeline configuration
|
||||
*
|
||||
* Saves the complete pipeline configuration for a project.
|
||||
*
|
||||
* Request body: { projectPath: string, config: PipelineConfig }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSaveConfigHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, config } = req.body as {
|
||||
projectPath: string;
|
||||
config: PipelineConfig;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
res.status(400).json({ success: false, error: 'config is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.savePipelineConfig(projectPath, config);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Save pipeline config failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/update - Update an existing pipeline step
|
||||
*
|
||||
* Updates a step in the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepId: string, updates: Partial<PipelineStep> }
|
||||
* Response: { success: true, step: PipelineStep }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineStep } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createUpdateStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepId, updates } = req.body as {
|
||||
projectPath: string;
|
||||
stepId: string;
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepId) {
|
||||
res.status(400).json({ success: false, error: 'stepId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
res.status(400).json({ success: false, error: 'updates is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
step: updatedStep,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Update pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { ExecuteOptions, Feature } from '@automaker/types';
|
||||
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import {
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../lib/sdk-options.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { pipelineService, PipelineService } from './pipeline-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
@@ -633,6 +634,23 @@ export class AutoModeService {
|
||||
}
|
||||
);
|
||||
|
||||
// Check for pipeline steps and execute them
|
||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||
|
||||
if (sortedSteps.length > 0) {
|
||||
// Execute pipeline steps sequentially
|
||||
await this.executePipelineSteps(
|
||||
projectPath,
|
||||
featureId,
|
||||
feature,
|
||||
sortedSteps,
|
||||
workDir,
|
||||
abortController,
|
||||
autoLoadClaudeMd
|
||||
);
|
||||
}
|
||||
|
||||
// Determine final status based on testing mode:
|
||||
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
|
||||
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
|
||||
@@ -676,6 +694,143 @@ export class AutoModeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute pipeline steps sequentially after initial feature implementation
|
||||
*/
|
||||
private async executePipelineSteps(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
feature: Feature,
|
||||
steps: PipelineStep[],
|
||||
workDir: string,
|
||||
abortController: AbortController,
|
||||
autoLoadClaudeMd: boolean
|
||||
): Promise<void> {
|
||||
console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
||||
|
||||
// Load context files once
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
});
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
// Load previous agent output for context continuity
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
let previousContext = '';
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
// No previous context
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const pipelineStatus = `pipeline_${step.id}`;
|
||||
|
||||
// Update feature status to current pipeline step
|
||||
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
this.emitAutoModeEvent('pipeline_step_started', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Build prompt for this pipeline step
|
||||
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext);
|
||||
|
||||
// Get model from feature
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
|
||||
// Run the agent for this pipeline step
|
||||
await this.runAgent(
|
||||
workDir,
|
||||
featureId,
|
||||
prompt,
|
||||
abortController,
|
||||
projectPath,
|
||||
undefined, // no images for pipeline steps
|
||||
model,
|
||||
{
|
||||
projectPath,
|
||||
planningMode: 'skip', // Pipeline steps don't need planning
|
||||
requirePlanApproval: false,
|
||||
previousContent: previousContext,
|
||||
systemPrompt: contextFilesPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
}
|
||||
);
|
||||
|
||||
// Load updated context for next step
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
// No context update
|
||||
}
|
||||
|
||||
this.emitAutoModeEvent('pipeline_step_complete', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for a pipeline step
|
||||
*/
|
||||
private buildPipelineStepPrompt(
|
||||
step: PipelineStep,
|
||||
feature: Feature,
|
||||
previousContext: string
|
||||
): string {
|
||||
let prompt = `## Pipeline Step: ${step.name}
|
||||
|
||||
This is an automated pipeline step following the initial feature implementation.
|
||||
|
||||
### Feature Context
|
||||
${this.buildFeaturePrompt(feature)}
|
||||
|
||||
`;
|
||||
|
||||
if (previousContext) {
|
||||
prompt += `### Previous Work
|
||||
The following is the output from the previous work on this feature:
|
||||
|
||||
${previousContext}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `### Pipeline Step Instructions
|
||||
${step.instructions}
|
||||
|
||||
### Task
|
||||
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific feature
|
||||
*/
|
||||
|
||||
320
apps/server/src/services/pipeline-service.ts
Normal file
320
apps/server/src/services/pipeline-service.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Pipeline Service - Handles reading/writing pipeline configuration
|
||||
*
|
||||
* Provides persistent storage for:
|
||||
* - Pipeline configuration ({projectPath}/.automaker/pipeline.json)
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { ensureAutomakerDir } from '@automaker/platform';
|
||||
import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('PipelineService');
|
||||
|
||||
// Default empty pipeline config
|
||||
const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for pipeline steps
|
||||
*/
|
||||
function generateStepId(): string {
|
||||
return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pipeline config file path for a project
|
||||
*/
|
||||
function getPipelineConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, '.automaker', 'pipeline.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* PipelineService - Manages pipeline configuration for workflow automation
|
||||
*
|
||||
* Handles reading and writing pipeline config to JSON files with atomic operations.
|
||||
* Pipeline steps define custom columns that appear between "in_progress" and
|
||||
* "waiting_approval/verified" columns in the kanban board.
|
||||
*/
|
||||
export class PipelineService {
|
||||
/**
|
||||
* Get pipeline configuration for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @returns Promise resolving to PipelineConfig (empty steps array if no config exists)
|
||||
*/
|
||||
async getPipelineConfig(projectPath: string): Promise<PipelineConfig> {
|
||||
const configPath = getPipelineConfigPath(projectPath);
|
||||
const config = await readJsonFile<PipelineConfig>(configPath, DEFAULT_PIPELINE_CONFIG);
|
||||
|
||||
// Ensure version is set
|
||||
return {
|
||||
...DEFAULT_PIPELINE_CONFIG,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save entire pipeline configuration
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param config - Complete PipelineConfig to save
|
||||
*/
|
||||
async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise<void> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const configPath = getPipelineConfigPath(projectPath);
|
||||
await atomicWriteJson(configPath, config);
|
||||
logger.info(`Pipeline config saved for project: ${projectPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param step - Step data (without id, createdAt, updatedAt)
|
||||
* @returns Promise resolving to the created PipelineStep
|
||||
*/
|
||||
async addStep(
|
||||
projectPath: string,
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<PipelineStep> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newStep: PipelineStep = {
|
||||
...step,
|
||||
id: generateStepId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
config.steps.push(newStep);
|
||||
|
||||
// Normalize order values
|
||||
config.steps.sort((a, b) => a.order - b.order);
|
||||
config.steps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`);
|
||||
|
||||
return newStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to update
|
||||
* @param updates - Partial step data to merge
|
||||
*/
|
||||
async updateStep(
|
||||
projectPath: string,
|
||||
stepId: string,
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
|
||||
): Promise<PipelineStep> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
|
||||
|
||||
if (stepIndex === -1) {
|
||||
throw new Error(`Pipeline step not found: ${stepId}`);
|
||||
}
|
||||
|
||||
config.steps[stepIndex] = {
|
||||
...config.steps[stepIndex],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step updated: ${stepId}`);
|
||||
|
||||
return config.steps[stepIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to delete
|
||||
*/
|
||||
async deleteStep(projectPath: string, stepId: string): Promise<void> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
|
||||
|
||||
if (stepIndex === -1) {
|
||||
throw new Error(`Pipeline step not found: ${stepId}`);
|
||||
}
|
||||
|
||||
config.steps.splice(stepIndex, 1);
|
||||
|
||||
// Normalize order values after deletion
|
||||
config.steps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step deleted: ${stepId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder pipeline steps
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepIds - Array of step IDs in the desired order
|
||||
*/
|
||||
async reorderSteps(projectPath: string, stepIds: string[]): Promise<void> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
|
||||
// Validate all step IDs exist
|
||||
const existingIds = new Set(config.steps.map((s) => s.id));
|
||||
for (const id of stepIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
throw new Error(`Pipeline step not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
|
||||
|
||||
// Reorder steps based on stepIds array
|
||||
config.steps = stepIds.map((id, index) => {
|
||||
const step = stepMap.get(id)!;
|
||||
return { ...step, order: index, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline steps reordered`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next status in the pipeline flow
|
||||
*
|
||||
* Determines what status a feature should transition to based on current status.
|
||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
||||
*
|
||||
* @param currentStatus - Current feature status
|
||||
* @param config - Pipeline configuration (or null if no pipeline)
|
||||
* @param skipTests - Whether to skip tests (affects final status)
|
||||
* @returns The next status in the pipeline flow
|
||||
*/
|
||||
getNextStatus(
|
||||
currentStatus: FeatureStatusWithPipeline,
|
||||
config: PipelineConfig | null,
|
||||
skipTests: boolean
|
||||
): FeatureStatusWithPipeline {
|
||||
const steps = config?.steps || [];
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
|
||||
// If no pipeline steps, use original logic
|
||||
if (sortedSteps.length === 0) {
|
||||
if (currentStatus === 'in_progress') {
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
// Coming from in_progress -> go to first pipeline step
|
||||
if (currentStatus === 'in_progress') {
|
||||
return `pipeline_${sortedSteps[0].id}`;
|
||||
}
|
||||
|
||||
// Coming from a pipeline step -> go to next step or final status
|
||||
if (currentStatus.startsWith('pipeline_')) {
|
||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// Step not found, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
if (currentIndex < sortedSteps.length - 1) {
|
||||
// Go to next step
|
||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
||||
}
|
||||
|
||||
// Last step completed, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
// For other statuses, don't change
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific pipeline step by ID
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to retrieve
|
||||
* @returns The pipeline step or null if not found
|
||||
*/
|
||||
async getStep(projectPath: string, stepId: string): Promise<PipelineStep | null> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
return config.steps.find((s) => s.id === stepId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status is a pipeline status
|
||||
*/
|
||||
isPipelineStatus(status: FeatureStatusWithPipeline): boolean {
|
||||
return status.startsWith('pipeline_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract step ID from a pipeline status
|
||||
*/
|
||||
getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null {
|
||||
if (!this.isPipelineStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
return status.replace('pipeline_', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pipelineService = new PipelineService();
|
||||
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createGetConfigHandler } from '@/routes/pipeline/routes/get-config.js';
|
||||
import { createSaveConfigHandler } from '@/routes/pipeline/routes/save-config.js';
|
||||
import { createAddStepHandler } from '@/routes/pipeline/routes/add-step.js';
|
||||
import { createUpdateStepHandler } from '@/routes/pipeline/routes/update-step.js';
|
||||
import { createDeleteStepHandler } from '@/routes/pipeline/routes/delete-step.js';
|
||||
import { createReorderStepsHandler } from '@/routes/pipeline/routes/reorder-steps.js';
|
||||
import type { PipelineService } from '@/services/pipeline-service.js';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
|
||||
describe('pipeline routes', () => {
|
||||
let mockPipelineService: PipelineService;
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPipelineService = {
|
||||
getPipelineConfig: vi.fn(),
|
||||
savePipelineConfig: vi.fn(),
|
||||
addStep: vi.fn(),
|
||||
updateStep: vi.fn(),
|
||||
deleteStep: vi.fn(),
|
||||
reorderSteps: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('get-config', () => {
|
||||
it('should return pipeline config successfully', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.getPipelineConfig).mockResolvedValue(config);
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.getPipelineConfig).toHaveBeenCalledWith('/test/project');
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = {};
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
expect(mockPipelineService.getPipelineConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Read failed');
|
||||
vi.mocked(mockPipelineService.getPipelineConfig).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Read failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('save-config', () => {
|
||||
it('should save pipeline config successfully', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.savePipelineConfig).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', config };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.savePipelineConfig).toHaveBeenCalledWith('/test/project', config);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { config: { version: 1, steps: [] } };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if config is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'config is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Save failed');
|
||||
vi.mocked(mockPipelineService.savePipelineConfig).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
config: { version: 1, steps: [] },
|
||||
};
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Save failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add-step', () => {
|
||||
it('should add step successfully', async () => {
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
};
|
||||
|
||||
const newStep: PipelineStep = {
|
||||
...stepData,
|
||||
id: 'step1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.addStep).mockResolvedValue(newStep);
|
||||
req.body = { projectPath: '/test/project', step: stepData };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.addStep).toHaveBeenCalledWith('/test/project', stepData);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
step: newStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' } };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step.name is missing', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { order: 0, instructions: 'Do', colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step.name is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step.instructions is missing', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { name: 'Step', order: 0, colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step.instructions is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Add failed');
|
||||
vi.mocked(mockPipelineService.addStep).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Add failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-step', () => {
|
||||
it('should update step successfully', async () => {
|
||||
const updates = {
|
||||
name: 'Updated Name',
|
||||
instructions: 'Updated instructions',
|
||||
};
|
||||
|
||||
const updatedStep: PipelineStep = {
|
||||
id: 'step1',
|
||||
name: 'Updated Name',
|
||||
order: 0,
|
||||
instructions: 'Updated instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.updateStep).mockResolvedValue(updatedStep);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1', updates };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.updateStep).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'step1',
|
||||
updates
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
step: updatedStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepId: 'step1', updates: { name: 'New' } };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepId is missing', async () => {
|
||||
req.body = { projectPath: '/test/project', updates: { name: 'New' } };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepId is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if updates is missing', async () => {
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'updates is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if updates is empty object', async () => {
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1', updates: {} };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'updates is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Update failed');
|
||||
vi.mocked(mockPipelineService.updateStep).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
stepId: 'step1',
|
||||
updates: { name: 'New' },
|
||||
};
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Update failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete-step', () => {
|
||||
it('should delete step successfully', async () => {
|
||||
vi.mocked(mockPipelineService.deleteStep).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.deleteStep).toHaveBeenCalledWith('/test/project', 'step1');
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepId is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepId is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Delete failed');
|
||||
vi.mocked(mockPipelineService.deleteStep).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Delete failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder-steps', () => {
|
||||
it('should reorder steps successfully', async () => {
|
||||
vi.mocked(mockPipelineService.reorderSteps).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', stepIds: ['step2', 'step1', 'step3'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.reorderSteps).toHaveBeenCalledWith('/test/project', [
|
||||
'step2',
|
||||
'step1',
|
||||
'step3',
|
||||
]);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepIds: ['step1', 'step2'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepIds is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepIds array is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepIds is not an array', async () => {
|
||||
req.body = { projectPath: '/test/project', stepIds: 'not-an-array' };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepIds array is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Reorder failed');
|
||||
vi.mocked(mockPipelineService.reorderSteps).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project', stepIds: ['step1', 'step2'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Reorder failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
@@ -0,0 +1,860 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { PipelineService } from '@/services/pipeline-service.js';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ensureAutomakerDir
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
ensureAutomakerDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { ensureAutomakerDir } from '@automaker/platform';
|
||||
|
||||
describe('pipeline-service.ts', () => {
|
||||
let testProjectDir: string;
|
||||
let pipelineService: PipelineService;
|
||||
|
||||
beforeEach(async () => {
|
||||
testProjectDir = path.join(os.tmpdir(), `pipeline-test-${Date.now()}`);
|
||||
await fs.mkdir(testProjectDir, { recursive: true });
|
||||
pipelineService = new PipelineService();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testProjectDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('getPipelineConfig', () => {
|
||||
it('should return default config when file does not exist', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(config).toEqual({
|
||||
version: 1,
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should read and return existing config', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(secureFs.readFile).toHaveBeenCalledWith(configPath, 'utf-8');
|
||||
expect(config).toEqual(existingConfig);
|
||||
});
|
||||
|
||||
it('should merge with defaults for missing properties', async () => {
|
||||
const partialConfig = {
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(partialConfig) as any);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(config.version).toBe(1);
|
||||
expect(config.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle read errors gracefully', async () => {
|
||||
const error = new Error('Read error');
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
// Should return default config on error
|
||||
expect(config).toEqual({
|
||||
version: 1,
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePipelineConfig', () => {
|
||||
it('should save config to file', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.savePipelineConfig(testProjectDir, config);
|
||||
|
||||
expect(ensureAutomakerDir).toHaveBeenCalledWith(testProjectDir);
|
||||
expect(secureFs.writeFile).toHaveBeenCalled();
|
||||
expect(secureFs.rename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use atomic write pattern', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.savePipelineConfig(testProjectDir, config);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const tempPath = writeCall[0] as string;
|
||||
expect(tempPath).toContain('.tmp.');
|
||||
expect(tempPath).toContain('pipeline.json');
|
||||
});
|
||||
|
||||
it('should clean up temp file on write error', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
vi.mocked(secureFs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await expect(pipelineService.savePipelineConfig(testProjectDir, config)).rejects.toThrow(
|
||||
'Write failed'
|
||||
);
|
||||
|
||||
expect(secureFs.unlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addStep', () => {
|
||||
it('should add a new step to config', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
};
|
||||
|
||||
const newStep = await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
expect(newStep.name).toBe('New Step');
|
||||
expect(newStep.id).toMatch(/^step_/);
|
||||
expect(newStep.createdAt).toBeDefined();
|
||||
expect(newStep.updatedAt).toBeDefined();
|
||||
expect(newStep.createdAt).toBe(newStep.updatedAt);
|
||||
});
|
||||
|
||||
it('should normalize order values after adding step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 5, // Out of order
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 10, // Out of order
|
||||
instructions: 'Do something',
|
||||
colorClass: 'red',
|
||||
};
|
||||
|
||||
await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
});
|
||||
|
||||
it('should sort steps by order before normalizing', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 2,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 0,
|
||||
instructions: 'Do something else',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 1,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'red',
|
||||
};
|
||||
|
||||
await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
// Should be sorted: step2 (order 0), newStep (order 1), step1 (order 2)
|
||||
expect(savedConfig.steps[0].id).toBe('step2');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
expect(savedConfig.steps[2].id).toBe('step1');
|
||||
expect(savedConfig.steps[2].order).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStep', () => {
|
||||
it('should update an existing step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Old Name',
|
||||
order: 0,
|
||||
instructions: 'Old instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const updates = {
|
||||
name: 'New Name',
|
||||
instructions: 'New instructions',
|
||||
};
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', updates);
|
||||
|
||||
expect(updatedStep.name).toBe('New Name');
|
||||
expect(updatedStep.instructions).toBe('New instructions');
|
||||
expect(updatedStep.id).toBe('step1');
|
||||
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(updatedStep.updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should throw error if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(
|
||||
pipelineService.updateStep(testProjectDir, 'nonexistent', { name: 'New' })
|
||||
).rejects.toThrow('Pipeline step not found: nonexistent');
|
||||
});
|
||||
|
||||
it('should preserve createdAt when updating', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', {
|
||||
name: 'Updated',
|
||||
});
|
||||
|
||||
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteStep', () => {
|
||||
it('should delete an existing step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.deleteStep(testProjectDir, 'step1');
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps).toHaveLength(1);
|
||||
expect(savedConfig.steps[0].id).toBe('step2');
|
||||
expect(savedConfig.steps[0].order).toBe(0); // Normalized
|
||||
});
|
||||
|
||||
it('should throw error if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(pipelineService.deleteStep(testProjectDir, 'nonexistent')).rejects.toThrow(
|
||||
'Pipeline step not found: nonexistent'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize order values after deletion', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 5, // Out of order
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 10, // Out of order
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.deleteStep(testProjectDir, 'step2');
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps).toHaveLength(2);
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderSteps', () => {
|
||||
it('should reorder steps according to stepIds array', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step3', 'step1', 'step2']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].id).toBe('step3');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].id).toBe('step1');
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
expect(savedConfig.steps[2].id).toBe('step2');
|
||||
expect(savedConfig.steps[2].order).toBe(2);
|
||||
});
|
||||
|
||||
it('should update updatedAt timestamp for reordered steps', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step2', 'step1']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(savedConfig.steps[1].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should throw error if step ID not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(
|
||||
pipelineService.reorderSteps(testProjectDir, ['step1', 'nonexistent'])
|
||||
).rejects.toThrow('Pipeline step not found: nonexistent');
|
||||
});
|
||||
|
||||
it('should allow partial reordering (filtering steps)', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step1']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
// Should only keep step1, effectively filtering out step2
|
||||
expect(savedConfig.steps).toHaveLength(1);
|
||||
expect(savedConfig.steps[0].id).toBe('step1');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextStatus', () => {
|
||||
it('should return waiting_approval when no pipeline and skipTests is true', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', null, true);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should return verified when no pipeline and skipTests is false', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', null, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should return first pipeline step when coming from in_progress', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should go to next pipeline step when in middle of pipeline', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step2');
|
||||
});
|
||||
|
||||
it('should go to final status when completing last pipeline step', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should go to waiting_approval when completing last step with skipTests', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should handle invalid pipeline step ID gracefully', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should preserve other statuses unchanged', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
expect(pipelineService.getNextStatus('backlog', config, false)).toBe('backlog');
|
||||
expect(pipelineService.getNextStatus('waiting_approval', config, false)).toBe(
|
||||
'waiting_approval'
|
||||
);
|
||||
expect(pipelineService.getNextStatus('verified', config, false)).toBe('verified');
|
||||
expect(pipelineService.getNextStatus('completed', config, false)).toBe('completed');
|
||||
});
|
||||
|
||||
it('should sort steps by order when determining next status', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStep', () => {
|
||||
it('should return step by ID', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const step = await pipelineService.getStep(testProjectDir, 'step1');
|
||||
|
||||
expect(step).not.toBeNull();
|
||||
expect(step?.id).toBe('step1');
|
||||
expect(step?.name).toBe('Step 1');
|
||||
});
|
||||
|
||||
it('should return null if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const step = await pipelineService.getStep(testProjectDir, 'nonexistent');
|
||||
|
||||
expect(step).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPipelineStatus', () => {
|
||||
it('should return true for pipeline statuses', () => {
|
||||
expect(pipelineService.isPipelineStatus('pipeline_step1')).toBe(true);
|
||||
expect(pipelineService.isPipelineStatus('pipeline_abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-pipeline statuses', () => {
|
||||
expect(pipelineService.isPipelineStatus('in_progress')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('waiting_approval')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('verified')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('backlog')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepIdFromStatus', () => {
|
||||
it('should extract step ID from pipeline status', () => {
|
||||
expect(pipelineService.getStepIdFromStatus('pipeline_step1')).toBe('step1');
|
||||
expect(pipelineService.getStepIdFromStatus('pipeline_abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should return null for non-pipeline statuses', () => {
|
||||
expect(pipelineService.getStepIdFromStatus('in_progress')).toBeNull();
|
||||
expect(pipelineService.getStepIdFromStatus('waiting_approval')).toBeNull();
|
||||
expect(pipelineService.getStepIdFromStatus('verified')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user