Merge origin/main into feat/cursor-cli

Merges latest main branch changes including:
- MCP server support and configuration
- Pipeline configuration system
- Prompt customization settings
- GitHub issue comments in validation
- Auth middleware improvements
- Various UI/UX improvements

All Cursor CLI features preserved:
- Multi-provider support (Claude + Cursor)
- Model override capabilities
- Phase model configuration
- Provider tabs in settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-31 01:22:18 +01:00
163 changed files with 15300 additions and 1045 deletions

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockExpressContext } from '../../utils/mocks.js';
import fs from 'fs';
import path from 'path';
/**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
@@ -8,26 +10,13 @@ import { createMockExpressContext } from '../../utils/mocks.js';
describe('auth.ts', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.AUTOMAKER_API_KEY;
delete process.env.AUTOMAKER_HIDE_API_KEY;
delete process.env.NODE_ENV;
});
describe('authMiddleware - no API key', () => {
it('should call next() when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('authMiddleware - with API key', () => {
it('should reject request without API key header', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
describe('authMiddleware', () => {
it('should reject request without any authentication', async () => {
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
@@ -36,7 +25,7 @@ describe('auth.ts', () => {
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Authentication required. Provide X-API-Key header.',
error: 'Authentication required.',
});
expect(next).not.toHaveBeenCalled();
});
@@ -70,46 +59,340 @@ describe('auth.ts', () => {
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with session token in header', async () => {
const { authMiddleware, createSession } = await import('@/lib/auth.js');
const token = await createSession();
const { req, res, next } = createMockExpressContext();
req.headers['x-session-token'] = token;
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should reject invalid session token in header', async () => {
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.headers['x-session-token'] = 'invalid-token';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid or expired session token.',
});
expect(next).not.toHaveBeenCalled();
});
it('should authenticate with API key in query parameter', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.query.apiKey = 'test-secret-key';
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with session cookie', async () => {
const { authMiddleware, createSession, getSessionCookieName } = await import('@/lib/auth.js');
const token = await createSession();
const cookieName = getSessionCookieName();
const { req, res, next } = createMockExpressContext();
req.cookies = { [cookieName]: token };
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('createSession', () => {
it('should create a new session and return token', async () => {
const { createSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
});
it('should create unique tokens for each session', async () => {
const { createSession } = await import('@/lib/auth.js');
const token1 = await createSession();
const token2 = await createSession();
expect(token1).not.toBe(token2);
});
});
describe('validateSession', () => {
it('should validate a valid session token', async () => {
const { createSession, validateSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(validateSession(token)).toBe(true);
});
it('should reject invalid session token', async () => {
const { validateSession } = await import('@/lib/auth.js');
expect(validateSession('invalid-token')).toBe(false);
});
it('should reject expired session token', async () => {
vi.useFakeTimers();
const { createSession, validateSession } = await import('@/lib/auth.js');
const token = await createSession();
// Advance time past session expiration (30 days)
vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000);
expect(validateSession(token)).toBe(false);
vi.useRealTimers();
});
});
describe('invalidateSession', () => {
it('should invalidate a session token', async () => {
const { createSession, validateSession, invalidateSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(validateSession(token)).toBe(true);
await invalidateSession(token);
expect(validateSession(token)).toBe(false);
});
});
describe('createWsConnectionToken', () => {
it('should create a WebSocket connection token', async () => {
const { createWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
});
it('should create unique tokens', async () => {
const { createWsConnectionToken } = await import('@/lib/auth.js');
const token1 = createWsConnectionToken();
const token2 = createWsConnectionToken();
expect(token1).not.toBe(token2);
});
});
describe('validateWsConnectionToken', () => {
it('should validate a valid WebSocket token', async () => {
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(validateWsConnectionToken(token)).toBe(true);
});
it('should reject invalid WebSocket token', async () => {
const { validateWsConnectionToken } = await import('@/lib/auth.js');
expect(validateWsConnectionToken('invalid-token')).toBe(false);
});
it('should reject expired WebSocket token', async () => {
vi.useFakeTimers();
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
// Advance time past token expiration (5 minutes)
vi.advanceTimersByTime(6 * 60 * 1000);
expect(validateWsConnectionToken(token)).toBe(false);
vi.useRealTimers();
});
it('should invalidate token after first use (single-use)', async () => {
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(validateWsConnectionToken(token)).toBe(true);
// Token should be deleted after first use
expect(validateWsConnectionToken(token)).toBe(false);
});
});
describe('validateApiKey', () => {
it('should validate correct API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('test-secret-key')).toBe(true);
});
it('should reject incorrect API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('wrong-key')).toBe(false);
});
it('should reject empty string', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('')).toBe(false);
});
it('should reject null/undefined', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey(null as any)).toBe(false);
expect(validateApiKey(undefined as any)).toBe(false);
});
it('should use timing-safe comparison for different lengths', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
// Key with different length should be rejected without timing leak
expect(validateApiKey('short')).toBe(false);
expect(validateApiKey('very-long-key-that-does-not-match')).toBe(false);
});
});
describe('getSessionCookieOptions', () => {
it('should return cookie options with httpOnly true', async () => {
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.httpOnly).toBe(true);
expect(options.sameSite).toBe('strict');
expect(options.path).toBe('/');
expect(options.maxAge).toBeGreaterThan(0);
});
it('should set secure to true in production', async () => {
process.env.NODE_ENV = 'production';
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.secure).toBe(true);
});
it('should set secure to false in non-production', async () => {
process.env.NODE_ENV = 'development';
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.secure).toBe(false);
});
});
describe('getSessionCookieName', () => {
it('should return the session cookie name', async () => {
const { getSessionCookieName } = await import('@/lib/auth.js');
const name = getSessionCookieName();
expect(name).toBe('automaker_session');
});
});
describe('isRequestAuthenticated', () => {
it('should return true for authenticated request with API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { isRequestAuthenticated } = await import('@/lib/auth.js');
const { req } = createMockExpressContext();
req.headers['x-api-key'] = 'test-secret-key';
expect(isRequestAuthenticated(req)).toBe(true);
});
it('should return false for unauthenticated request', async () => {
const { isRequestAuthenticated } = await import('@/lib/auth.js');
const { req } = createMockExpressContext();
expect(isRequestAuthenticated(req)).toBe(false);
});
it('should return true for authenticated request with session token', async () => {
const { isRequestAuthenticated, createSession } = await import('@/lib/auth.js');
const token = await createSession();
const { req } = createMockExpressContext();
req.headers['x-session-token'] = token;
expect(isRequestAuthenticated(req)).toBe(true);
});
});
describe('checkRawAuthentication', () => {
it('should return true for valid API key in headers', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({ 'x-api-key': 'test-secret-key' }, {}, {})).toBe(true);
});
it('should return true for valid session token in headers', async () => {
const { checkRawAuthentication, createSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(checkRawAuthentication({ 'x-session-token': token }, {}, {})).toBe(true);
});
it('should return true for valid API key in query', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({}, { apiKey: 'test-secret-key' }, {})).toBe(true);
});
it('should return true for valid session cookie', async () => {
const { checkRawAuthentication, createSession, getSessionCookieName } =
await import('@/lib/auth.js');
const token = await createSession();
const cookieName = getSessionCookieName();
expect(checkRawAuthentication({}, {}, { [cookieName]: token })).toBe(true);
});
it('should return false for invalid credentials', async () => {
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({}, {}, {})).toBe(false);
});
});
describe('isAuthEnabled', () => {
it('should return false when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(false);
});
it('should return true when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = 'test-key';
it('should always return true (auth is always required)', async () => {
const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(true);
});
});
describe('getAuthStatus', () => {
it('should return disabled status when no API key', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus();
expect(status).toEqual({
enabled: false,
method: 'none',
});
});
it('should return enabled status when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = 'test-key';
it('should return enabled status with api_key_or_session method', async () => {
const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus();
expect(status).toEqual({
enabled: true,
method: 'api_key',
method: 'api_key_or_session',
});
});
});

View File

@@ -179,7 +179,7 @@ describe('sdk-options.ts', () => {
it('should create options with chat settings', async () => {
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
const options = createChatOptions({ cwd: '/test/path' });
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true });
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.standard);
@@ -212,6 +212,27 @@ describe('sdk-options.ts', () => {
expect(options.model).toBe('claude-sonnet-4-20250514');
});
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should not set sandbox when enableSandboxMode is not provided', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
});
expect(options.sandbox).toBeUndefined();
});
});
describe('createAutoModeOptions', () => {
@@ -219,7 +240,7 @@ describe('sdk-options.ts', () => {
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({ cwd: '/test/path' });
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true });
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
@@ -252,6 +273,27 @@ describe('sdk-options.ts', () => {
expect(options.abortController).toBe(abortController);
});
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should not set sandbox when enableSandboxMode is not provided', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
});
expect(options.sandbox).toBeUndefined();
});
});
describe('createCustomOptions', () => {

View File

@@ -0,0 +1,378 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js';
import type { SettingsService } from '@/services/settings-service.js';
// Mock the logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual('@automaker/utils');
const mockLogger = {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
return {
...actual,
createLogger: () => mockLogger,
};
});
describe('settings-helpers.ts', () => {
describe('getMCPServersFromSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return empty object when settingsService is null', async () => {
const result = await getMCPServersFromSettings(null);
expect(result).toEqual({});
});
it('should return empty object when settingsService is undefined', async () => {
const result = await getMCPServersFromSettings(undefined);
expect(result).toEqual({});
});
it('should return empty object when no MCP servers configured', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should return empty object when mcpServers is undefined', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should convert enabled stdio server to SDK format', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'test-server',
type: 'stdio',
command: 'node',
args: ['server.js'],
env: { NODE_ENV: 'test' },
enabled: true,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({
'test-server': {
type: 'stdio',
command: 'node',
args: ['server.js'],
env: { NODE_ENV: 'test' },
},
});
});
it('should convert enabled SSE server to SDK format', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'sse-server',
type: 'sse',
url: 'http://localhost:3000/sse',
headers: { Authorization: 'Bearer token' },
enabled: true,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({
'sse-server': {
type: 'sse',
url: 'http://localhost:3000/sse',
headers: { Authorization: 'Bearer token' },
},
});
});
it('should convert enabled HTTP server to SDK format', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'http-server',
type: 'http',
url: 'http://localhost:3000/api',
headers: { 'X-API-Key': 'secret' },
enabled: true,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({
'http-server': {
type: 'http',
url: 'http://localhost:3000/api',
headers: { 'X-API-Key': 'secret' },
},
});
});
it('should filter out disabled servers', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'enabled-server',
type: 'stdio',
command: 'node',
enabled: true,
},
{
id: '2',
name: 'disabled-server',
type: 'stdio',
command: 'python',
enabled: false,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(Object.keys(result)).toHaveLength(1);
expect(result['enabled-server']).toBeDefined();
expect(result['disabled-server']).toBeUndefined();
});
it('should treat servers without enabled field as enabled', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'implicit-enabled',
type: 'stdio',
command: 'node',
// enabled field not set
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result['implicit-enabled']).toBeDefined();
});
it('should handle multiple enabled servers', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{ id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true },
{ id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true },
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(Object.keys(result)).toHaveLength(2);
expect(result['server1']).toBeDefined();
expect(result['server2']).toBeDefined();
});
it('should return empty object and log error on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService, '[Test]');
expect(result).toEqual({});
// Logger will be called with error, but we don't need to assert it
});
it('should throw error for SSE server without URL', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'bad-sse',
type: 'sse',
enabled: true,
// url missing
},
],
}),
} as unknown as SettingsService;
// The error is caught and logged, returns empty
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should throw error for HTTP server without URL', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'bad-http',
type: 'http',
enabled: true,
// url missing
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should throw error for stdio server without command', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'bad-stdio',
type: 'stdio',
enabled: true,
// command missing
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should default to stdio type when type is not specified', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'no-type',
command: 'node',
enabled: true,
// type not specified, should default to stdio
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result['no-type']).toEqual({
type: 'stdio',
command: 'node',
args: undefined,
env: undefined,
});
});
});
describe('getMCPPermissionSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return defaults when settingsService is null', async () => {
const result = await getMCPPermissionSettings(null);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
});
it('should return defaults when settingsService is undefined', async () => {
const result = await getMCPPermissionSettings(undefined);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
});
it('should return settings from service', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpAutoApproveTools: false,
mcpUnrestrictedTools: false,
}),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService);
expect(result).toEqual({
mcpAutoApproveTools: false,
mcpUnrestrictedTools: false,
});
});
it('should default to true when settings are undefined', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
});
it('should handle mixed settings', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: false,
}),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: false,
});
});
it('should return defaults and log error on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService, '[Test]');
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
// Logger will be called with error, but we don't need to assert it
});
it('should use custom log prefix', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
}),
} as unknown as SettingsService;
await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]');
// Logger will be called with custom prefix, but we don't need to assert it
});
});
});

View File

@@ -73,7 +73,7 @@ describe('claude-provider.ts', () => {
maxTurns: 10,
cwd: '/test/dir',
allowedTools: ['Read', 'Write'],
permissionMode: 'acceptEdits',
permissionMode: 'default',
}),
});
});
@@ -100,7 +100,7 @@ describe('claude-provider.ts', () => {
});
});
it('should enable sandbox by default', async () => {
it('should pass sandbox configuration when provided', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
@@ -110,6 +110,10 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
});
await collectAsyncGenerator(generator);
@@ -242,10 +246,16 @@ describe('claude-provider.ts', () => {
});
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[ClaudeProvider] executeQuery() error during execution:',
testError
);
// Should log error with classification info (after refactoring)
const errorCall = consoleErrorSpy.mock.calls[0];
expect(errorCall[0]).toBe('[ClaudeProvider] executeQuery() error during execution:');
expect(errorCall[1]).toMatchObject({
type: expect.any(String),
message: 'SDK execution failed',
isRateLimit: false,
stack: expect.stringContaining('Error: SDK execution failed'),
});
consoleErrorSpy.mockRestore();
});

View 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',
});
});
});
});

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createIndexHandler } from '@/routes/running-agents/routes/index.js';
import type { AutoModeService } from '@/services/auto-mode-service.js';
import { createMockExpressContext } from '../../utils/mocks.js';
describe('running-agents routes', () => {
let mockAutoModeService: Partial<AutoModeService>;
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
mockAutoModeService = {
getRunningAgents: vi.fn(),
};
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('GET / (index handler)', () => {
it('should return empty array when no agents are running', async () => {
// Arrange
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue([]);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(mockAutoModeService.getRunningAgents).toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents: [],
totalCount: 0,
});
});
it('should return running agents with all properties', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-123',
projectPath: '/home/user/project',
projectName: 'project',
isAutoMode: true,
title: 'Implement login feature',
description: 'Add user authentication with OAuth',
},
{
featureId: 'feature-456',
projectPath: '/home/user/other-project',
projectName: 'other-project',
isAutoMode: false,
title: 'Fix navigation bug',
description: undefined,
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents,
totalCount: 2,
});
});
it('should return agents without title/description (backward compatibility)', async () => {
// Arrange
const runningAgents = [
{
featureId: 'legacy-feature',
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
title: undefined,
description: undefined,
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents,
totalCount: 1,
});
});
it('should handle errors gracefully and return 500', async () => {
// Arrange
const error = new Error('Database connection failed');
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue(error);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Database connection failed',
});
});
it('should handle non-Error exceptions', async () => {
// Arrange
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue('String error');
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: expect.any(String),
});
});
it('should correctly count multiple running agents', async () => {
// Arrange
const runningAgents = Array.from({ length: 10 }, (_, i) => ({
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
projectName: `project-${i}`,
isAutoMode: i % 2 === 0,
title: `Feature ${i}`,
description: `Description ${i}`,
}));
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents,
totalCount: 10,
});
});
it('should include agents from different projects', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-a',
projectPath: '/workspace/project-alpha',
projectName: 'project-alpha',
isAutoMode: true,
title: 'Feature A',
description: 'In project alpha',
},
{
featureId: 'feature-b',
projectPath: '/workspace/project-beta',
projectName: 'project-beta',
isAutoMode: false,
title: 'Feature B',
description: 'In project beta',
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
const response = vi.mocked(res.json).mock.calls[0][0];
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
});
});
});

View File

@@ -7,9 +7,26 @@ import * as promptBuilder from '@automaker/utils';
import * as contextLoader from '@automaker/utils';
import { collectAsyncGenerator } from '../../utils/helpers.js';
// Create a shared mock logger instance for assertions using vi.hoisted
const mockLogger = vi.hoisted(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}));
vi.mock('fs/promises');
vi.mock('@/providers/provider-factory.js');
vi.mock('@automaker/utils');
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
loadContextFiles: vi.fn(),
buildPromptWithImages: vi.fn(),
readImageAsBase64: vi.fn(),
createLogger: vi.fn(() => mockLogger),
};
});
describe('agent-service.ts', () => {
let service: AgentService;
@@ -224,16 +241,13 @@ describe('agent-service.ts', () => {
hasImages: false,
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await service.sendMessage({
sessionId: 'session-1',
message: 'Check this',
imagePaths: ['/path/test.png'],
});
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
expect(mockLogger.error).toHaveBeenCalled();
});
it('should use custom model if provided', async () => {
@@ -347,4 +361,386 @@ describe('agent-service.ts', () => {
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe('createSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue('{}');
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should create a new session with metadata', async () => {
const session = await service.createSession('Test Session', '/test/project', '/test/dir');
expect(session.id).toBeDefined();
expect(session.name).toBe('Test Session');
expect(session.projectPath).toBe('/test/project');
expect(session.workingDirectory).toBeDefined();
expect(session.createdAt).toBeDefined();
expect(session.updatedAt).toBeDefined();
});
it('should use process.cwd() if no working directory provided', async () => {
const session = await service.createSession('Test Session');
expect(session.workingDirectory).toBeDefined();
});
it('should validate working directory', async () => {
// Set ALLOWED_ROOT_DIRECTORY to restrict paths
const originalAllowedRoot = process.env.ALLOWED_ROOT_DIRECTORY;
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/projects';
// Re-import platform to initialize with new env var
vi.resetModules();
const { initAllowedPaths } = await import('@automaker/platform');
initAllowedPaths();
const { AgentService } = await import('@/services/agent-service.js');
const testService = new AgentService('/test/data', mockEvents as any);
vi.mocked(fs.readFile).mockResolvedValue('{}');
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await expect(
testService.createSession('Test Session', undefined, '/invalid/path')
).rejects.toThrow();
// Restore original value
if (originalAllowedRoot) {
process.env.ALLOWED_ROOT_DIRECTORY = originalAllowedRoot;
} else {
delete process.env.ALLOWED_ROOT_DIRECTORY;
}
vi.resetModules();
const { initAllowedPaths: reinit } = await import('@automaker/platform');
reinit();
});
});
describe('setSessionModel', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should set model for existing session', async () => {
vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}');
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514');
expect(result).toBe(false);
});
});
describe('updateSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should update session metadata', async () => {
const result = await service.updateSession('session-1', { name: 'Updated Name' });
expect(result).not.toBeNull();
expect(result?.name).toBe('Updated Name');
expect(result?.updatedAt).not.toBe('2024-01-01T00:00:00Z');
});
it('should return null for non-existent session', async () => {
const result = await service.updateSession('nonexistent', { name: 'Updated Name' });
expect(result).toBeNull();
});
});
describe('archiveSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should archive a session', async () => {
const result = await service.archiveSession('session-1');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.archiveSession('nonexistent');
expect(result).toBe(false);
});
});
describe('unarchiveSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
archived: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should unarchive a session', async () => {
const result = await service.unarchiveSession('session-1');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.unarchiveSession('nonexistent');
expect(result).toBe(false);
});
});
describe('deleteSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.unlink).mockResolvedValue(undefined);
});
it('should delete a session', async () => {
const result = await service.deleteSession('session-1');
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalled();
});
it('should return false for non-existent session', async () => {
const result = await service.deleteSession('nonexistent');
expect(result).toBe(false);
});
});
describe('listSessions', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session 1',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
archived: false,
},
'session-2': {
id: 'session-2',
name: 'Test Session 2',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
archived: true,
},
})
);
});
it('should list non-archived sessions by default', async () => {
const sessions = await service.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe('session-1');
});
it('should include archived sessions when requested', async () => {
const sessions = await service.listSessions(true);
expect(sessions.length).toBe(2);
});
it('should sort sessions by updatedAt descending', async () => {
const sessions = await service.listSessions(true);
expect(sessions[0].id).toBe('session-2');
expect(sessions[1].id).toBe('session-1');
});
});
describe('addToQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should add prompt to queue', async () => {
const result = await service.addToQueue('session-1', {
message: 'Test prompt',
imagePaths: ['/test/image.png'],
model: 'claude-sonnet-4-20250514',
});
expect(result.success).toBe(true);
expect(result.queuedPrompt).toBeDefined();
expect(result.queuedPrompt?.message).toBe('Test prompt');
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.addToQueue('nonexistent', {
message: 'Test prompt',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
describe('getQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should return queue for session', async () => {
await service.addToQueue('session-1', { message: 'Test prompt' });
const result = service.getQueue('session-1');
expect(result.success).toBe(true);
expect(result.queue).toBeDefined();
expect(result.queue?.length).toBe(1);
});
it('should return error for non-existent session', () => {
const result = service.getQueue('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
describe('removeFromQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
const addResult = await service.addToQueue('session-1', { message: 'Test prompt' });
vi.clearAllMocks();
});
it('should remove prompt from queue', async () => {
const queueResult = service.getQueue('session-1');
const promptId = queueResult.queue![0].id;
const result = await service.removeFromQueue('session-1', promptId);
expect(result.success).toBe(true);
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.removeFromQueue('nonexistent', 'prompt-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
it('should return error for non-existent prompt', async () => {
const result = await service.removeFromQueue('session-1', 'nonexistent-prompt-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Prompt not found in queue');
});
});
describe('clearQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
await service.addToQueue('session-1', { message: 'Test prompt 1' });
await service.addToQueue('session-1', { message: 'Test prompt 2' });
vi.clearAllMocks();
});
it('should clear all prompts from queue', async () => {
const result = await service.clearQueue('session-1');
expect(result.success).toBe(true);
const queueResult = service.getQueue('session-1');
expect(queueResult.queue?.length).toBe(0);
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.clearQueue('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
});

View File

@@ -24,84 +24,87 @@ describe('auto-mode-service.ts - Planning Mode', () => {
return svc.getPlanningPromptPrefix(feature);
};
it('should return empty string for skip mode', () => {
it('should return empty string for skip mode', async () => {
const feature = { id: 'test', planningMode: 'skip' as const };
const result = getPlanningPromptPrefix(service, feature);
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return empty string when planningMode is undefined', () => {
it('should return empty string when planningMode is undefined', async () => {
const feature = { id: 'test' };
const result = getPlanningPromptPrefix(service, feature);
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return lite prompt for lite mode without approval', () => {
it('should return lite prompt for lite mode without approval', async () => {
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: false,
};
const result = getPlanningPromptPrefix(service, feature);
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain('[PLAN_GENERATED]');
expect(result).toContain('Feature Request');
});
it('should return lite_with_approval prompt for lite mode with approval', () => {
it('should return lite_with_approval prompt for lite mode with approval', async () => {
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: true,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Planning Phase (Lite Mode)');
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Planning Phase (Lite Mode)');
expect(result).toContain('[SPEC_GENERATED]');
expect(result).toContain('DO NOT proceed with implementation');
expect(result).toContain(
'DO NOT proceed with implementation until you receive explicit approval'
);
});
it('should return spec prompt for spec mode', () => {
it('should return spec prompt for spec mode', async () => {
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Specification Phase (Spec Mode)');
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Specification Phase (Spec Mode)');
expect(result).toContain('```tasks');
expect(result).toContain('T001');
expect(result).toContain('[TASK_START]');
expect(result).toContain('[TASK_COMPLETE]');
});
it('should return full prompt for full mode', () => {
it('should return full prompt for full mode', async () => {
const feature = {
id: 'test',
planningMode: 'full' as const,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Full Specification Phase (Full SDD Mode)');
expect(result).toContain('Phase 1: Foundation');
expect(result).toContain('Phase 2: Core Implementation');
expect(result).toContain('Phase 3: Integration & Testing');
});
it('should include the separator and Feature Request header', () => {
it('should include the separator and Feature Request header', async () => {
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = getPlanningPromptPrefix(service, feature);
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('---');
expect(result).toContain('## Feature Request');
});
it('should instruct agent to NOT output exploration text', () => {
it('should instruct agent to NOT output exploration text', async () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const feature = { id: 'test', planningMode: mode };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Do NOT output exploration text');
expect(result).toContain('Start DIRECTLY');
const result = await getPlanningPromptPrefix(service, feature);
// All modes should have the IMPORTANT instruction about not outputting exploration text
expect(result).toContain('IMPORTANT: Do NOT output exploration text');
expect(result).toContain('Silently analyze the codebase first');
}
});
});
@@ -279,18 +282,18 @@ describe('auto-mode-service.ts - Planning Mode', () => {
return svc.getPlanningPromptPrefix(feature);
};
it('should have all required planning modes', () => {
it('should have all required planning modes', async () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const feature = { id: 'test', planningMode: mode };
const result = getPlanningPromptPrefix(service, feature);
const result = await getPlanningPromptPrefix(service, feature);
expect(result.length).toBeGreaterThan(100);
}
});
it('lite prompt should include correct structure', () => {
it('lite prompt should include correct structure', async () => {
const feature = { id: 'test', planningMode: 'lite' as const };
const result = getPlanningPromptPrefix(service, feature);
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('Goal');
expect(result).toContain('Approach');
expect(result).toContain('Files to Touch');
@@ -298,9 +301,9 @@ describe('auto-mode-service.ts - Planning Mode', () => {
expect(result).toContain('Risks');
});
it('spec prompt should include task format instructions', () => {
it('spec prompt should include task format instructions', async () => {
const feature = { id: 'test', planningMode: 'spec' as const };
const result = getPlanningPromptPrefix(service, feature);
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('Problem');
expect(result).toContain('Solution');
expect(result).toContain('Acceptance Criteria');
@@ -309,13 +312,13 @@ describe('auto-mode-service.ts - Planning Mode', () => {
expect(result).toContain('Verification');
});
it('full prompt should include phases', () => {
it('full prompt should include phases', async () => {
const feature = { id: 'test', planningMode: 'full' as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Problem Statement');
expect(result).toContain('User Story');
expect(result).toContain('Technical Context');
expect(result).toContain('Non-Goals');
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('1. **Problem Statement**');
expect(result).toContain('2. **User Story**');
expect(result).toContain('4. **Technical Context**');
expect(result).toContain('5. **Non-Goals**');
expect(result).toContain('Phase 1');
expect(result).toContain('Phase 2');
expect(result).toContain('Phase 3');

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AutoModeService } from '@/services/auto-mode-service.js';
import type { Feature } from '@automaker/types';
describe('auto-mode-service.ts', () => {
let service: AutoModeService;
@@ -66,4 +67,252 @@ describe('auto-mode-service.ts', () => {
expect(runningCount).toBe(0);
});
});
describe('getRunningAgents', () => {
// Helper to access private runningFeatures Map
const getRunningFeaturesMap = (svc: AutoModeService) =>
(svc as any).runningFeatures as Map<
string,
{ featureId: string; projectPath: string; isAutoMode: boolean }
>;
// Helper to get the featureLoader and mock its get method
const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).featureLoader = { get: mockFn };
};
it('should return empty array when no agents are running', async () => {
const result = await service.getRunningAgents();
expect(result).toEqual([]);
});
it('should return running agents with basic info when feature data is not available', async () => {
// Arrange: Add a running feature to the Map
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-123', {
featureId: 'feature-123',
projectPath: '/test/project/path',
isAutoMode: true,
});
// Mock featureLoader.get to return null (feature not found)
const getMock = vi.fn().mockResolvedValue(null);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-123',
projectPath: '/test/project/path',
projectName: 'path',
isAutoMode: true,
title: undefined,
description: undefined,
});
});
it('should return running agents with title and description when feature data is available', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-456', {
featureId: 'feature-456',
projectPath: '/home/user/my-project',
isAutoMode: false,
});
const mockFeature: Partial<Feature> = {
id: 'feature-456',
title: 'Implement user authentication',
description: 'Add login and signup functionality',
category: 'auth',
};
const getMock = vi.fn().mockResolvedValue(mockFeature);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-456',
projectPath: '/home/user/my-project',
projectName: 'my-project',
isAutoMode: false,
title: 'Implement user authentication',
description: 'Add login and signup functionality',
});
expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456');
});
it('should handle multiple running agents', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
const getMock = vi
.fn()
.mockResolvedValueOnce({
id: 'feature-1',
title: 'Feature One',
description: 'Description one',
})
.mockResolvedValueOnce({
id: 'feature-2',
title: 'Feature Two',
description: 'Description two',
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(2);
expect(getMock).toHaveBeenCalledTimes(2);
});
it('should silently handle errors when fetching feature data', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-error', {
featureId: 'feature-error',
projectPath: '/project-error',
isAutoMode: true,
});
const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed'));
mockFeatureLoaderGet(service, getMock);
// Act - should not throw
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-error',
projectPath: '/project-error',
projectName: 'project-error',
isAutoMode: true,
title: undefined,
description: undefined,
});
});
it('should handle feature with title but no description', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-title-only', {
featureId: 'feature-title-only',
projectPath: '/project',
isAutoMode: false,
});
const getMock = vi.fn().mockResolvedValue({
id: 'feature-title-only',
title: 'Only Title',
// description is undefined
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].title).toBe('Only Title');
expect(result[0].description).toBeUndefined();
});
it('should handle feature with description but no title', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-desc-only', {
featureId: 'feature-desc-only',
projectPath: '/project',
isAutoMode: false,
});
const getMock = vi.fn().mockResolvedValue({
id: 'feature-desc-only',
description: 'Only description, no title',
// title is undefined
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].title).toBeUndefined();
expect(result[0].description).toBe('Only description, no title');
});
it('should extract projectName from nested paths correctly', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-nested', {
featureId: 'feature-nested',
projectPath: '/home/user/workspace/projects/my-awesome-project',
isAutoMode: true,
});
const getMock = vi.fn().mockResolvedValue(null);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].projectName).toBe('my-awesome-project');
});
it('should fetch feature data in parallel for multiple agents', async () => {
// Arrange: Add multiple running features
const runningFeaturesMap = getRunningFeaturesMap(service);
for (let i = 1; i <= 5; i++) {
runningFeaturesMap.set(`feature-${i}`, {
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
isAutoMode: i % 2 === 0,
});
}
// Track call order
const callOrder: string[] = [];
const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => {
callOrder.push(featureId);
// Simulate async delay to verify parallel execution
await new Promise((resolve) => setTimeout(resolve, 10));
return { id: featureId, title: `Title for ${featureId}` };
});
mockFeatureLoaderGet(service, getMock);
// Act
const startTime = Date.now();
const result = await service.getRunningAgents();
const duration = Date.now() - startTime;
// Assert
expect(result).toHaveLength(5);
expect(getMock).toHaveBeenCalledTimes(5);
// If executed in parallel, total time should be ~10ms (one batch)
// If sequential, it would be ~50ms (5 * 10ms)
// Allow some buffer for execution overhead
expect(duration).toBeLessThan(40);
});
});
});

View File

@@ -485,7 +485,7 @@ Resets in 2h
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout', async () => {
it('should handle timeout with no data', async () => {
vi.useFakeTimers();
mockSpawnProcess.stdout = {
@@ -619,7 +619,7 @@ Resets in 2h
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout on Windows', async () => {
it('should handle timeout with no data on Windows', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
@@ -640,5 +640,69 @@ Resets in 2h
vi.useRealTimers();
});
it('should return data on timeout if data was captured', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
let dataCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
// Simulate receiving usage data
dataCallback!('Current session\n65% left\nResets in 2h');
// Advance time past timeout (30 seconds)
vi.advanceTimersByTime(31000);
// Should resolve with data instead of rejecting
const result = await promise;
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(mockPty.kill).toHaveBeenCalled();
vi.useRealTimers();
});
it('should send SIGTERM after ESC if process does not exit', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
let dataCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
windowsService.fetchUsageData();
// Simulate seeing usage data
dataCallback!('Current session\n65% left');
// Advance 2s to trigger ESC
vi.advanceTimersByTime(2100);
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
// Advance another 2s to trigger SIGTERM fallback
vi.advanceTimersByTime(2100);
expect(mockPty.kill).toHaveBeenCalledWith('SIGTERM');
vi.useRealTimers();
});
});
});

View 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();
});
});
});