mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
14
.geminiignore
Normal file
14
.geminiignore
Normal file
@@ -0,0 +1,14 @@
|
||||
# Auto-generated by Automaker to speed up Gemini CLI startup
|
||||
# Prevents Gemini CLI from scanning large directories during context discovery
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
coverage
|
||||
.automaker
|
||||
.worktrees
|
||||
.vscode
|
||||
.idea
|
||||
*.lock
|
||||
38
.github/workflows/e2e-tests.yml
vendored
38
.github/workflows/e2e-tests.yml
vendored
@@ -13,6 +13,13 @@ jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# shardIndex: [1, 2, 3]
|
||||
# shardTotal: [3]
|
||||
shardIndex: [1]
|
||||
shardTotal: [1]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -91,7 +98,7 @@ jobs:
|
||||
curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
# Check if server process is still running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process died during wait!"
|
||||
@@ -99,7 +106,7 @@ jobs:
|
||||
cat backend.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
echo "Waiting... ($i/60)"
|
||||
sleep 1
|
||||
done
|
||||
@@ -127,17 +134,23 @@ jobs:
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
- name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
# Playwright automatically starts the Vite frontend via webServer config
|
||||
# (see apps/ui/playwright.config.ts) - no need to start it manually
|
||||
run: npm run test --workspace=apps/ui
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
working-directory: apps/ui
|
||||
env:
|
||||
CI: true
|
||||
VITE_SERVER_URL: http://localhost:3108
|
||||
SERVER_URL: http://localhost:3108
|
||||
VITE_SKIP_SETUP: 'true'
|
||||
# Keep UI-side login/defaults consistent
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
# Backend is already started above - Playwright config sets
|
||||
# AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend.
|
||||
# Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes
|
||||
# a cookie domain mismatch (cookies are bound to 127.0.0.1, but
|
||||
# VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost).
|
||||
TEST_USE_EXTERNAL_BACKEND: 'true'
|
||||
TEST_SERVER_PORT: 3108
|
||||
|
||||
- name: Print backend logs on failure
|
||||
if: failure()
|
||||
@@ -155,7 +168,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
|
||||
path: apps/ui/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -163,12 +176,21 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
|
||||
path: |
|
||||
apps/ui/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload blob report for merging
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
|
||||
path: apps/ui/blob-report/
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Cleanup - Kill backend server
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -65,6 +65,17 @@ coverage/
|
||||
*.lcov
|
||||
playwright-report/
|
||||
blob-report/
|
||||
test/**/test-project-[0-9]*/
|
||||
test/opus-thinking-*/
|
||||
test/agent-session-test-*/
|
||||
test/feature-backlog-test-*/
|
||||
test/running-task-display-test-*/
|
||||
test/agent-output-modal-responsive-*/
|
||||
test/fixtures/
|
||||
test/board-bg-test-*/
|
||||
test/edit-feature-test-*/
|
||||
test/open-project-test-*/
|
||||
|
||||
|
||||
# Environment files (keep .example)
|
||||
.env
|
||||
@@ -102,3 +113,4 @@ data/
|
||||
.planning/
|
||||
.mcp.json
|
||||
.planning
|
||||
.bg-shell/
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.15.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@github/copilot-sdk": "0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
|
||||
@@ -261,7 +261,10 @@ morgan.token('status-colored', (_req, res) => {
|
||||
app.use(
|
||||
morgan(':method :url :status-colored', {
|
||||
// Skip when request logging is disabled or for health check endpoints
|
||||
skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
|
||||
skip: (req) =>
|
||||
!requestLoggingEnabled ||
|
||||
req.url === '/api/health' ||
|
||||
req.url === '/api/auto-mode/context-exists',
|
||||
})
|
||||
);
|
||||
// CORS configuration
|
||||
@@ -349,7 +352,9 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
devServerService.setEventEmitter(events);
|
||||
devServerService.initialize(DATA_DIR, events).catch((err) => {
|
||||
logger.error('Failed to initialize DevServerService:', err);
|
||||
});
|
||||
|
||||
// Initialize Notification Service with event emitter for real-time updates
|
||||
const notificationService = getNotificationService();
|
||||
@@ -434,21 +439,18 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
||||
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
||||
}
|
||||
|
||||
// Resume interrupted features in the background after reconciliation.
|
||||
// This uses the saved execution state to identify features that were running
|
||||
// before the restart (their statuses have been reset to ready/backlog by
|
||||
// reconciliation above). Running in background so it doesn't block startup.
|
||||
if (totalReconciled > 0) {
|
||||
for (const project of globalSettings.projects) {
|
||||
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
|
||||
logger.warn(
|
||||
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
logger.info('[STARTUP] Initiated background resume of interrupted features');
|
||||
// Resume interrupted features in the background for all projects.
|
||||
// This handles features stuck in transient states (in_progress, pipeline_*)
|
||||
// or explicitly marked as interrupted. Running in background so it doesn't block startup.
|
||||
for (const project of globalSettings.projects) {
|
||||
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
|
||||
logger.warn(
|
||||
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
logger.info('[STARTUP] Initiated background resume of interrupted features');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
||||
@@ -494,7 +496,7 @@ app.use(
|
||||
);
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
@@ -596,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
// Subscribe to all events and forward to this client
|
||||
const unsubscribe = events.subscribe((type, payload) => {
|
||||
logger.info('Event received:', {
|
||||
// Use debug level for high-frequency events to avoid log spam
|
||||
// that causes progressive memory growth and server slowdown
|
||||
const isHighFrequency =
|
||||
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
|
||||
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
|
||||
log('Event received:', {
|
||||
type,
|
||||
hasPayload: !!payload,
|
||||
payloadKeys: payload ? Object.keys(payload) : [],
|
||||
wsReadyState: ws.readyState,
|
||||
wsOpen: ws.readyState === WebSocket.OPEN,
|
||||
});
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const message = JSON.stringify({ type, payload });
|
||||
logger.info('Sending event to client:', {
|
||||
type,
|
||||
messageLength: message.length,
|
||||
sessionId: (payload as Record<string, unknown>)?.sessionId,
|
||||
});
|
||||
ws.send(message);
|
||||
} else {
|
||||
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
|
||||
logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,27 @@ import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('GitLib');
|
||||
|
||||
// Extended PATH so git is found when the process does not inherit a full shell PATH
|
||||
// (e.g. Electron, some CI, or IDE-launched processes).
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const extraPaths: string[] =
|
||||
process.platform === 'win32'
|
||||
? ([
|
||||
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
|
||||
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
|
||||
process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`,
|
||||
].filter(Boolean) as string[])
|
||||
: [
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/usr/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
process.env.HOME ? `${process.env.HOME}/.local/bin` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator);
|
||||
const gitEnv = { ...process.env, PATH: extendedPath };
|
||||
|
||||
// ============================================================================
|
||||
// Secure Command Execution
|
||||
// ============================================================================
|
||||
@@ -65,7 +86,14 @@ export async function execGitCommand(
|
||||
command: 'git',
|
||||
args,
|
||||
cwd,
|
||||
...(env !== undefined ? { env } : {}),
|
||||
env:
|
||||
env !== undefined
|
||||
? {
|
||||
...gitEnv,
|
||||
...env,
|
||||
PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator),
|
||||
}
|
||||
: gitEnv,
|
||||
...(abortController !== undefined ? { abortController } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -689,6 +689,145 @@ export interface ProviderByModelIdResult {
|
||||
resolvedModel: string | undefined;
|
||||
}
|
||||
|
||||
/** Result from resolveProviderContext */
|
||||
export interface ProviderContextResult {
|
||||
/** The provider configuration */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** Credentials for API key resolution */
|
||||
credentials: Credentials | undefined;
|
||||
/** The resolved Claude model ID for SDK configuration */
|
||||
resolvedModel: string | undefined;
|
||||
/** The original model config from the provider if found */
|
||||
modelConfig: import('@automaker/types').ProviderModel | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a provider is enabled.
|
||||
* Providers with enabled: undefined are treated as enabled (default state).
|
||||
* Only explicitly set enabled: false means the provider is disabled.
|
||||
*/
|
||||
function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean {
|
||||
return provider.enabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a model config in a provider's models array by ID (case-insensitive).
|
||||
*/
|
||||
function findModelInProvider(
|
||||
provider: ClaudeCompatibleProvider,
|
||||
modelId: string
|
||||
): import('@automaker/types').ProviderModel | undefined {
|
||||
return provider.models?.find(
|
||||
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the provider and Claude-compatible model configuration.
|
||||
*
|
||||
* This is the central logic for resolving provider context, supporting:
|
||||
* 1. Explicit lookup by providerId (most reliable for persistence)
|
||||
* 2. Fallback lookup by modelId across all enabled providers
|
||||
* 3. Resolution of mapsToClaudeModel for SDK configuration
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @param modelId - The model ID to resolve
|
||||
* @param providerId - Optional explicit provider ID
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to the provider context
|
||||
*/
|
||||
export async function resolveProviderContext(
|
||||
settingsService: SettingsService,
|
||||
modelId: string,
|
||||
providerId?: string,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ProviderContextResult> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
logger.debug(
|
||||
`${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}`
|
||||
);
|
||||
|
||||
let provider: ClaudeCompatibleProvider | undefined;
|
||||
let modelConfig: import('@automaker/types').ProviderModel | undefined;
|
||||
|
||||
// 1. Try resolving by explicit providerId first (most reliable)
|
||||
if (providerId) {
|
||||
provider = providers.find((p) => p.id === providerId);
|
||||
if (provider) {
|
||||
if (!isProviderEnabled(provider)) {
|
||||
logger.warn(
|
||||
`${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}`
|
||||
);
|
||||
// Find the model config within this provider to check for mappings
|
||||
modelConfig = findModelInProvider(provider, modelId);
|
||||
if (!modelConfig && provider.models && provider.models.length > 0) {
|
||||
logger.debug(
|
||||
`${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to model-based lookup across all providers if modelConfig not found
|
||||
// Note: We still search even if provider was found, to get the modelConfig for mapping
|
||||
if (!modelConfig) {
|
||||
for (const p of providers) {
|
||||
if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked
|
||||
|
||||
const config = findModelInProvider(p, modelId);
|
||||
|
||||
if (config) {
|
||||
// Only override provider if we didn't find one by explicit ID
|
||||
if (!provider) {
|
||||
provider = p;
|
||||
}
|
||||
modelConfig = config;
|
||||
logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve the mapped Claude model if specified
|
||||
let resolvedModel: string | undefined;
|
||||
if (modelConfig?.mapsToClaudeModel) {
|
||||
const { resolveModelString } = await import('@automaker/model-resolver');
|
||||
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
|
||||
logger.debug(
|
||||
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Log final result for debugging
|
||||
logger.debug(
|
||||
`${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}`
|
||||
);
|
||||
|
||||
return { provider, credentials, resolvedModel, modelConfig };
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to resolve provider context:`, error);
|
||||
return {
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
resolvedModel: undefined,
|
||||
modelConfig: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a ClaudeCompatibleProvider by one of its model IDs.
|
||||
* Searches through all enabled providers to find one that contains the specified model.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Version utility - Reads version from package.json
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
@@ -24,7 +24,20 @@ export function getVersion(): string {
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
||||
const candidatePaths = [
|
||||
// Development via tsx: src/lib -> project root
|
||||
join(__dirname, '..', '..', 'package.json'),
|
||||
// Packaged/build output: lib -> server bundle root
|
||||
join(__dirname, '..', 'package.json'),
|
||||
];
|
||||
|
||||
const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate));
|
||||
if (!packageJsonPath) {
|
||||
throw new Error(
|
||||
`package.json not found in any expected location: ${candidatePaths.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
const version = packageJson.version || '0.0.0';
|
||||
cachedVersion = version;
|
||||
|
||||
@@ -188,6 +188,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
// Claude doesn't use a provider prefix, so we don't need to specify an expected provider
|
||||
validateBareModelId(options.model, 'ClaudeProvider');
|
||||
|
||||
const {
|
||||
|
||||
@@ -739,9 +739,9 @@ export class CodexProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// Validate that model doesn't have a provider prefix (except codex- which should already be stripped)
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
validateBareModelId(options.model, 'CodexProvider');
|
||||
validateBareModelId(options.model, 'CodexProvider', 'codex');
|
||||
|
||||
try {
|
||||
const mcpServers = options.mcpServers ?? {};
|
||||
|
||||
@@ -76,13 +76,18 @@ interface SdkToolExecutionStartEvent extends SdkEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkToolExecutionEndEvent extends SdkEvent {
|
||||
type: 'tool.execution_end';
|
||||
interface SdkToolExecutionCompleteEvent extends SdkEvent {
|
||||
type: 'tool.execution_complete';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
result?: {
|
||||
content: string;
|
||||
};
|
||||
error?: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +99,16 @@ interface SdkSessionErrorEvent extends SdkEvent {
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Prefix for error messages in tool results
|
||||
* Consistent with GeminiProvider's error formatting
|
||||
*/
|
||||
const TOOL_ERROR_PREFIX = '[ERROR]' as const;
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
@@ -357,12 +372,19 @@ export class CopilotProvider extends CliProvider {
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool.execution_end': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
|
||||
const isError = !!toolResultEvent.data.error;
|
||||
const content = isError
|
||||
? `[ERROR] ${toolResultEvent.data.error}`
|
||||
: toolResultEvent.data.result || '';
|
||||
/**
|
||||
* Tool execution completed event
|
||||
* Handles both successful results and errors from tool executions
|
||||
* Error messages optionally include error codes for better debugging
|
||||
*/
|
||||
case 'tool.execution_complete': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent;
|
||||
const error = toolResultEvent.data.error;
|
||||
|
||||
// Format error message with optional code for better debugging
|
||||
const content = error
|
||||
? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}`
|
||||
: toolResultEvent.data.result?.content || '';
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
@@ -628,7 +650,7 @@ export class CopilotProvider extends CliProvider {
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else {
|
||||
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
|
||||
// Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.)
|
||||
pushEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -843,9 +843,10 @@ export class CursorProvider extends CliProvider {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
validateBareModelId(options.model, 'CursorProvider');
|
||||
// Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
|
||||
validateBareModelId(options.model, 'CursorProvider', 'cursor');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
|
||||
@@ -546,8 +546,8 @@ export class GeminiProvider extends CliProvider {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
validateBareModelId(options.model, 'GeminiProvider');
|
||||
// Validate that model doesn't have a provider prefix (except gemini- which should already be stripped)
|
||||
validateBareModelId(options.model, 'GeminiProvider', 'gemini');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
|
||||
53
apps/server/src/providers/mock-provider.ts
Normal file
53
apps/server/src/providers/mock-provider.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Mock Provider - No-op AI provider for E2E and CI testing
|
||||
*
|
||||
* When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of
|
||||
* real backends (Claude, Codex, etc.) so tests never call external APIs.
|
||||
*/
|
||||
|
||||
import type { ExecuteOptions } from '@automaker/types';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js';
|
||||
|
||||
const MOCK_TEXT = 'Mock agent output for testing.';
|
||||
|
||||
export class MockProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
async *executeQuery(_options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: MOCK_TEXT }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
return {
|
||||
installed: true,
|
||||
method: 'sdk',
|
||||
hasApiKey: true,
|
||||
authenticated: true,
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: 'mock-model',
|
||||
name: 'Mock Model',
|
||||
modelString: 'mock-model',
|
||||
provider: 'mock',
|
||||
description: 'Mock model for testing',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
|
||||
* Format a display name for a model
|
||||
*/
|
||||
private formatModelDisplayName(model: OpenCodeModelInfo): string {
|
||||
// Extract the last path segment for nested model IDs
|
||||
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
|
||||
let rawName = model.name;
|
||||
if (rawName.includes('/')) {
|
||||
rawName = rawName.split('/').pop()!;
|
||||
}
|
||||
|
||||
// Strip tier/pricing suffixes like ":free", ":extended"
|
||||
const colonIdx = rawName.indexOf(':');
|
||||
let suffix = '';
|
||||
if (colonIdx !== -1) {
|
||||
const tierPart = rawName.slice(colonIdx + 1);
|
||||
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
|
||||
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
|
||||
}
|
||||
rawName = rawName.slice(0, colonIdx);
|
||||
}
|
||||
|
||||
// Capitalize and format the model name
|
||||
const formattedName = model.name
|
||||
const formattedName = rawName
|
||||
.split('-')
|
||||
.map((part) => {
|
||||
// Handle version numbers like "4-5" -> "4.5"
|
||||
@@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
};
|
||||
|
||||
const providerDisplay = providerNames[model.provider] || model.provider;
|
||||
return `${formattedName} (${providerDisplay})`;
|
||||
return `${formattedName}${suffix} (${providerDisplay})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,6 +67,16 @@ export function registerProvider(name: string, registration: ProviderRegistratio
|
||||
providerRegistry.set(name.toLowerCase(), registration);
|
||||
}
|
||||
|
||||
/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */
|
||||
let mockProviderInstance: BaseProvider | null = null;
|
||||
|
||||
function getMockProvider(): BaseProvider {
|
||||
if (!mockProviderInstance) {
|
||||
mockProviderInstance = new MockProvider();
|
||||
}
|
||||
return mockProviderInstance;
|
||||
}
|
||||
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
* Determine which provider to use for a given model
|
||||
@@ -75,6 +85,9 @@ export class ProviderFactory {
|
||||
* @returns Provider name (ModelProvider type)
|
||||
*/
|
||||
static getProviderNameForModel(model: string): ModelProvider {
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider
|
||||
}
|
||||
const lowerModel = model.toLowerCase();
|
||||
|
||||
// Get all registered providers sorted by priority (descending)
|
||||
@@ -113,6 +126,9 @@ export class ProviderFactory {
|
||||
modelId: string,
|
||||
options: { throwOnDisconnected?: boolean } = {}
|
||||
): BaseProvider {
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
return getMockProvider();
|
||||
}
|
||||
const { throwOnDisconnected = true } = options;
|
||||
const providerName = this.getProviderForModelName(modelId);
|
||||
|
||||
@@ -142,6 +158,9 @@ export class ProviderFactory {
|
||||
* Get the provider name for a given model ID (without creating provider instance)
|
||||
*/
|
||||
static getProviderForModelName(modelId: string): string {
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
return 'claude';
|
||||
}
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// Get all registered providers sorted by priority (descending)
|
||||
@@ -272,6 +291,7 @@ export class ProviderFactory {
|
||||
// =============================================================================
|
||||
|
||||
// Import providers for registration side-effects
|
||||
import { MockProvider } from './mock-provider.js';
|
||||
import { ClaudeProvider } from './claude-provider.js';
|
||||
import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
|
||||
@@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events);
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/
|
||||
import { getFeaturesDir } from '@automaker/platform';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getNotificationService } from '../../services/notification-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export async function parseAndCreateFeatures(
|
||||
projectPath: string,
|
||||
content: string,
|
||||
events: EventEmitter
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Promise<void> {
|
||||
logger.info('========== parseAndCreateFeatures() started ==========');
|
||||
logger.info(`Content length: ${content.length} chars`);
|
||||
@@ -23,6 +26,37 @@ export async function parseAndCreateFeatures(
|
||||
logger.info(content);
|
||||
logger.info('========== END CONTENT ==========');
|
||||
|
||||
// Load default model and planning settings from settingsService
|
||||
let defaultModel: string | undefined;
|
||||
let defaultPlanningMode: string = 'skip';
|
||||
let defaultRequirePlanApproval = false;
|
||||
|
||||
if (settingsService) {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
|
||||
const defaultModelEntry =
|
||||
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
|
||||
if (defaultModelEntry) {
|
||||
const resolved = resolvePhaseModel(defaultModelEntry);
|
||||
defaultModel = resolved.model;
|
||||
}
|
||||
|
||||
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
|
||||
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
|
||||
|
||||
logger.info(
|
||||
`[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
|
||||
);
|
||||
} catch (settingsError) {
|
||||
logger.warn(
|
||||
'[parseAndCreateFeatures] Failed to load settings, using defaults:',
|
||||
settingsError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract JSON from response using shared utility
|
||||
logger.info('Extracting JSON from response using extractJsonWithArray...');
|
||||
@@ -61,7 +95,7 @@ export async function parseAndCreateFeatures(
|
||||
const featureDir = path.join(featuresDir, feature.id);
|
||||
await secureFs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const featureData = {
|
||||
const featureData: Record<string, unknown> = {
|
||||
id: feature.id,
|
||||
category: feature.category || 'Uncategorized',
|
||||
title: feature.title,
|
||||
@@ -70,10 +104,20 @@ export async function parseAndCreateFeatures(
|
||||
priority: feature.priority || 2,
|
||||
complexity: feature.complexity || 'moderate',
|
||||
dependencies: feature.dependencies || [],
|
||||
planningMode: defaultPlanningMode,
|
||||
requirePlanApproval:
|
||||
defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
|
||||
? false
|
||||
: defaultRequirePlanApproval,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Apply default model if available from settings
|
||||
if (defaultModel) {
|
||||
featureData.model = defaultModel;
|
||||
}
|
||||
|
||||
// Use atomic write with backup support for crash protection
|
||||
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
|
||||
backupCount: DEFAULT_BACKUP_COUNT,
|
||||
|
||||
@@ -25,7 +25,7 @@ export function createBacklogPlanRoutes(
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
|
||||
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
|
||||
router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService));
|
||||
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
|
||||
|
||||
return router;
|
||||
|
||||
@@ -3,13 +3,23 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
export function createApplyHandler() {
|
||||
function normalizePhaseModelEntry(
|
||||
entry: PhaseModelEntry | string | undefined | null
|
||||
): PhaseModelEntry | undefined {
|
||||
if (!entry) return undefined;
|
||||
if (typeof entry === 'string') return { model: entry };
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function createApplyHandler(settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
@@ -38,6 +48,23 @@ export function createApplyHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultPlanningMode: PlanningMode = 'skip';
|
||||
let defaultRequirePlanApproval = false;
|
||||
let defaultModelEntry: PhaseModelEntry | undefined;
|
||||
|
||||
if (settingsService) {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
|
||||
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
|
||||
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
|
||||
defaultModelEntry = normalizePhaseModelEntry(
|
||||
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry);
|
||||
|
||||
const appliedChanges: string[] = [];
|
||||
|
||||
// Load current features for dependency validation
|
||||
@@ -88,6 +115,12 @@ export function createApplyHandler() {
|
||||
if (!change.feature) continue;
|
||||
|
||||
try {
|
||||
const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode;
|
||||
const effectiveRequirePlanApproval =
|
||||
effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite'
|
||||
? false
|
||||
: (change.feature.requirePlanApproval ?? defaultRequirePlanApproval);
|
||||
|
||||
// Create the new feature - use the AI-generated ID if provided
|
||||
const newFeature = await featureLoader.create(projectPath, {
|
||||
id: change.feature.id, // Use descriptive ID from AI if provided
|
||||
@@ -97,6 +130,12 @@ export function createApplyHandler() {
|
||||
dependencies: change.feature.dependencies,
|
||||
priority: change.feature.priority,
|
||||
status: 'backlog',
|
||||
model: change.feature.model ?? resolvedDefaultModel.model,
|
||||
thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel,
|
||||
reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort,
|
||||
providerId: change.feature.providerId ?? resolvedDefaultModel.providerId,
|
||||
planningMode: effectivePlanningMode,
|
||||
requirePlanApproval: effectiveRequirePlanApproval,
|
||||
branchName,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
import { createExportHandler } from './routes/export.js';
|
||||
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||
import {
|
||||
createOrphanedListHandler,
|
||||
createOrphanedResolveHandler,
|
||||
createOrphanedBulkResolveHandler,
|
||||
} from './routes/orphaned.js';
|
||||
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
@@ -70,6 +75,21 @@ export function createFeaturesRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createConflictCheckHandler(featureLoader)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned/resolve',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedResolveHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned/bulk-resolve',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedBulkResolveHandler(featureLoader)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function createListHandler(
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService
|
||||
.detectOrphanedFeatures(projectPath)
|
||||
.detectOrphanedFeatures(projectPath, features)
|
||||
.then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
|
||||
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
|
||||
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
|
||||
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('OrphanedFeatures');
|
||||
|
||||
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
|
||||
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
|
||||
|
||||
export function createOrphanedListHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoModeService) {
|
||||
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
|
||||
|
||||
res.json({ success: true, orphanedFeatures });
|
||||
} catch (error) {
|
||||
logError(error, 'Detect orphaned features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createOrphanedResolveHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
_autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, action, targetBranch } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
action: ResolveAction;
|
||||
targetBranch?: string | null;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !action) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath, featureId, and action are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_ACTIONS.includes(action)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resolveOrphanedFeature(
|
||||
featureLoader,
|
||||
projectPath,
|
||||
featureId,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Resolve orphaned feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface BulkResolveResult {
|
||||
featureId: string;
|
||||
success: boolean;
|
||||
action?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function resolveOrphanedFeature(
|
||||
featureLoader: FeatureLoader,
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
action: ResolveAction,
|
||||
targetBranch?: string | null
|
||||
): Promise<BulkResolveResult> {
|
||||
try {
|
||||
const feature = await featureLoader.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
return { featureId, success: false, error: 'Feature not found' };
|
||||
}
|
||||
|
||||
const missingBranch = feature.branchName;
|
||||
|
||||
switch (action) {
|
||||
case 'delete': {
|
||||
if (missingBranch) {
|
||||
try {
|
||||
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
const success = await featureLoader.delete(projectPath, featureId);
|
||||
if (!success) {
|
||||
return { featureId, success: false, error: 'Deletion failed' };
|
||||
}
|
||||
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
|
||||
return { featureId, success: true, action: 'deleted' };
|
||||
}
|
||||
|
||||
case 'create-worktree': {
|
||||
if (!missingBranch) {
|
||||
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
|
||||
}
|
||||
|
||||
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
|
||||
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
|
||||
|
||||
try {
|
||||
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
if (msg.includes('already exists')) {
|
||||
try {
|
||||
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
|
||||
} catch (innerError) {
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
|
||||
);
|
||||
return { featureId, success: true, action: 'worktree-created' };
|
||||
}
|
||||
|
||||
case 'move-to-branch': {
|
||||
// Move the feature to a different branch (or clear branch to use main worktree)
|
||||
const newBranch = targetBranch || null;
|
||||
|
||||
// Validate that the target branch exists if one is specified
|
||||
if (newBranch) {
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
|
||||
} catch {
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: `Target branch "${newBranch}" does not exist`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await featureLoader.update(projectPath, featureId, {
|
||||
branchName: newBranch,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Clean up old worktree metadata
|
||||
if (missingBranch) {
|
||||
try {
|
||||
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const destination = newBranch ?? 'main worktree';
|
||||
logger.info(
|
||||
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
|
||||
);
|
||||
return { featureId, success: true, action: 'moved' };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { featureId, success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureIds, action, targetBranch } = req.body as {
|
||||
projectPath: string;
|
||||
featureIds: string[];
|
||||
action: ResolveAction;
|
||||
targetBranch?: string | null;
|
||||
};
|
||||
|
||||
if (
|
||||
!projectPath ||
|
||||
!featureIds ||
|
||||
!Array.isArray(featureIds) ||
|
||||
featureIds.length === 0 ||
|
||||
!action
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath, featureIds (non-empty array), and action are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_ACTIONS.includes(action)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process sequentially for worktree creation (git operations shouldn't race),
|
||||
// in parallel for delete/move-to-branch
|
||||
const results: BulkResolveResult[] = [];
|
||||
|
||||
if (action === 'create-worktree') {
|
||||
for (const featureId of featureIds) {
|
||||
const result = await resolveOrphanedFeature(
|
||||
featureLoader,
|
||||
projectPath,
|
||||
featureId,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
} else {
|
||||
const batchResults = await Promise.all(
|
||||
featureIds.map((featureId) =>
|
||||
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
|
||||
)
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failedCount = results.length - successCount;
|
||||
|
||||
res.json({
|
||||
success: failedCount === 0,
|
||||
resolvedCount: successCount,
|
||||
failedCount,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Bulk resolve orphaned features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -43,7 +43,11 @@ export function createUpdateHandler(featureLoader: FeatureLoader, events?: Event
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
if (!currentFeature) {
|
||||
res.status(404).json({ success: false, error: `Feature ${featureId} not found` });
|
||||
return;
|
||||
}
|
||||
const previousStatus = currentFeature.status as FeatureStatus;
|
||||
const newStatus = updates.status as FeatureStatus | undefined;
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
|
||||
@@ -3,16 +3,29 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
// Optional files that are expected to not exist in new projects
|
||||
// Don't log ENOENT errors for these to reduce noise
|
||||
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt'];
|
||||
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json'];
|
||||
|
||||
function isOptionalFile(filePath: string): boolean {
|
||||
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
|
||||
const basename = path.basename(filePath);
|
||||
if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) {
|
||||
return true;
|
||||
}
|
||||
// Context and memory files may not exist yet during create/delete or test races
|
||||
if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) {
|
||||
const name = path.basename(filePath);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
@@ -39,12 +52,14 @@ export function createReadHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
|
||||
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
|
||||
if (shouldLog) {
|
||||
const filePath = req.body?.filePath || '';
|
||||
const optionalMissing = isENOENT(error) && isOptionalFile(filePath);
|
||||
if (!optionalMissing) {
|
||||
logError(error, 'Read file failed');
|
||||
}
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
// Return 404 for missing optional files so clients can handle "not found"
|
||||
const status = optionalMissing ? 404 : 500;
|
||||
res.status(status).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,16 @@ export function createStatHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// File or directory does not exist - return 404 so UI can handle missing paths
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code: string }).code
|
||||
: '';
|
||||
if (code === 'ENOENT') {
|
||||
res.status(404).json({ success: false, error: 'File or directory not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Get file stats failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getProviderByModelId,
|
||||
resolveProviderContext,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
@@ -64,6 +64,8 @@ interface ValidateIssueRequestBody {
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Reasoning effort for Codex models (ignored for non-Codex models) */
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
/** Optional Claude-compatible provider ID for custom providers (e.g., GLM, MiniMax) */
|
||||
providerId?: string;
|
||||
/** Comments to include in validation analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests for this issue */
|
||||
@@ -87,6 +89,7 @@ async function runValidation(
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
providerId?: string,
|
||||
comments?: ValidationComment[],
|
||||
linkedPRs?: ValidationLinkedPR[],
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
@@ -176,7 +179,12 @@ ${basePrompt}`;
|
||||
let credentials = await settingsService?.getCredentials();
|
||||
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
|
||||
const providerResult = await resolveProviderContext(
|
||||
settingsService,
|
||||
model,
|
||||
providerId,
|
||||
'[ValidateIssue]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
providerResolvedModel = providerResult.resolvedModel;
|
||||
@@ -312,10 +320,16 @@ export function createValidateIssueHandler(
|
||||
model = 'opus',
|
||||
thinkingLevel,
|
||||
reasoningEffort,
|
||||
providerId,
|
||||
comments: rawComments,
|
||||
linkedPRs: rawLinkedPRs,
|
||||
} = req.body as ValidateIssueRequestBody;
|
||||
|
||||
const normalizedProviderId =
|
||||
typeof providerId === 'string' && providerId.trim().length > 0
|
||||
? providerId.trim()
|
||||
: undefined;
|
||||
|
||||
// Transform GitHubComment[] to ValidationComment[] if provided
|
||||
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
|
||||
author: c.author?.login || 'ghost',
|
||||
@@ -364,12 +378,14 @@ export function createValidateIssueHandler(
|
||||
isClaudeModel(model) ||
|
||||
isCursorModel(model) ||
|
||||
isCodexModel(model) ||
|
||||
isOpencodeModel(model);
|
||||
isOpencodeModel(model) ||
|
||||
!!normalizedProviderId;
|
||||
|
||||
if (!isValidModel) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).',
|
||||
error:
|
||||
'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -398,6 +414,7 @@ export function createValidateIssueHandler(
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
normalizedProviderId,
|
||||
validationComments,
|
||||
validationLinkedPRs,
|
||||
thinkingLevel,
|
||||
|
||||
@@ -80,6 +80,12 @@ function containsAuthError(text: string): boolean {
|
||||
export function createVerifyClaudeAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// In E2E/CI mock mode, skip real API calls
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
res.json({ success: true, authenticated: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the auth method and optional API key from the request body
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
|
||||
@@ -82,6 +82,12 @@ function isRateLimitError(text: string): boolean {
|
||||
|
||||
export function createVerifyCodexAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
// In E2E/CI mock mode, skip real API calls
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
res.json({ success: true, authenticated: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
apiKey?: string;
|
||||
|
||||
@@ -71,10 +71,12 @@ import { createSetTrackingHandler } from './routes/set-tracking.js';
|
||||
import { createSyncHandler } from './routes/sync.js';
|
||||
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
settingsService?: SettingsService,
|
||||
featureLoader?: FeatureLoader
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -94,7 +96,11 @@ export function createWorktreeRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createCreateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||
router.post(
|
||||
'/delete',
|
||||
validatePathParams('projectPath', 'worktreePath'),
|
||||
createDeleteHandler(events, featureLoader)
|
||||
);
|
||||
router.post('/create-pr', createCreatePRHandler());
|
||||
router.post('/pr-info', createPRInfoHandler());
|
||||
router.post(
|
||||
|
||||
@@ -10,11 +10,13 @@ import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
export function createDeleteHandler() {
|
||||
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath, deleteBranch } = req.body as {
|
||||
@@ -134,12 +136,65 @@ export function createDeleteHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit worktree:deleted event after successful deletion
|
||||
events.emit('worktree:deleted', {
|
||||
worktreePath,
|
||||
projectPath,
|
||||
branchName,
|
||||
branchDeleted,
|
||||
});
|
||||
|
||||
// Move features associated with the deleted branch to the main worktree
|
||||
// This prevents features from being orphaned when a worktree is deleted
|
||||
let featuresMovedToMain = 0;
|
||||
if (featureLoader && branchName) {
|
||||
try {
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName);
|
||||
for (const feature of affectedFeatures) {
|
||||
try {
|
||||
await featureLoader.update(projectPath, feature.id, {
|
||||
branchName: null,
|
||||
});
|
||||
featuresMovedToMain++;
|
||||
// Emit feature:migrated event for each successfully migrated feature
|
||||
events.emit('feature:migrated', {
|
||||
featureId: feature.id,
|
||||
status: 'migrated',
|
||||
fromBranch: branchName,
|
||||
toWorktreeId: null, // migrated to main worktree (no specific worktree)
|
||||
projectPath,
|
||||
});
|
||||
} catch (featureUpdateError) {
|
||||
// Non-fatal: log per-feature failure but continue migrating others
|
||||
logger.warn('Failed to move feature to main worktree after deletion', {
|
||||
error: getErrorMessage(featureUpdateError),
|
||||
featureId: feature.id,
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (featuresMovedToMain > 0) {
|
||||
logger.info(
|
||||
`Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}`
|
||||
);
|
||||
}
|
||||
} catch (featureError) {
|
||||
// Non-fatal: log but don't fail the deletion (getAll failed)
|
||||
logger.warn('Failed to load features for migration to main worktree after deletion', {
|
||||
error: getErrorMessage(featureError),
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deleted: {
|
||||
worktreePath,
|
||||
branch: branchDeleted ? branchName : null,
|
||||
branchDeleted,
|
||||
featuresMovedToMain,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,13 +44,79 @@ export function createInitGitHandler() {
|
||||
}
|
||||
|
||||
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
|
||||
// and create an initial empty commit
|
||||
await execAsync(
|
||||
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
|
||||
{
|
||||
cwd: projectPath,
|
||||
// Run commands sequentially so failures can be handled and partial state cleaned up.
|
||||
let gitDirCreated = false;
|
||||
try {
|
||||
// Step 1: initialize the repository
|
||||
try {
|
||||
await execAsync(`git init --initial-branch=main`, { cwd: projectPath });
|
||||
} catch (initError: unknown) {
|
||||
const stderr =
|
||||
initError && typeof initError === 'object' && 'stderr' in initError
|
||||
? String((initError as { stderr?: string }).stderr)
|
||||
: '';
|
||||
// Idempotent: if .git was created by a concurrent request or a stale lock exists,
|
||||
// treat as "repo already exists" instead of failing
|
||||
if (
|
||||
/could not lock config file.*File exists|fatal: could not set 'core\.repositoryformatversion'/.test(
|
||||
stderr
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await secureFs.access(gitDirPath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
initialized: false,
|
||||
message: 'Git repository already exists',
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// .git still missing, rethrow original error
|
||||
}
|
||||
}
|
||||
throw initError;
|
||||
}
|
||||
);
|
||||
gitDirCreated = true;
|
||||
|
||||
// Step 2: ensure user.name and user.email are set so the commit can succeed.
|
||||
// Check the global/system config first; only set locally if missing.
|
||||
let userName = '';
|
||||
let userEmail = '';
|
||||
try {
|
||||
({ stdout: userName } = await execAsync(`git config user.name`, { cwd: projectPath }));
|
||||
} catch {
|
||||
// not set globally – will configure locally below
|
||||
}
|
||||
try {
|
||||
({ stdout: userEmail } = await execAsync(`git config user.email`, {
|
||||
cwd: projectPath,
|
||||
}));
|
||||
} catch {
|
||||
// not set globally – will configure locally below
|
||||
}
|
||||
|
||||
if (!userName.trim()) {
|
||||
await execAsync(`git config user.name "Automaker"`, { cwd: projectPath });
|
||||
}
|
||||
if (!userEmail.trim()) {
|
||||
await execAsync(`git config user.email "automaker@localhost"`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
// Step 3: create the initial empty commit
|
||||
await execAsync(`git commit --allow-empty -m "Initial commit"`, { cwd: projectPath });
|
||||
} catch (error: unknown) {
|
||||
// Clean up the partial .git directory so subsequent runs behave deterministically
|
||||
if (gitDirCreated) {
|
||||
try {
|
||||
await secureFs.rm(gitDirPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup; ignore errors
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logWorktreeError } from '../common.js';
|
||||
import { getErrorMessage, logWorktreeError, execGitCommand } from '../common.js';
|
||||
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface BranchInfo {
|
||||
@@ -36,18 +35,18 @@ export function createListBranchesHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Get current branch (execGitCommand avoids spawning /bin/sh; works in sandboxed CI)
|
||||
const currentBranchOutput = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
worktreePath
|
||||
);
|
||||
const currentBranch = currentBranchOutput.trim();
|
||||
|
||||
// List all local branches
|
||||
// Use double quotes around the format string for cross-platform compatibility
|
||||
// Single quotes are preserved literally on Windows; double quotes work on both
|
||||
const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const branchesOutput = await execGitCommand(
|
||||
['branch', '--format=%(refname:short)'],
|
||||
worktreePath
|
||||
);
|
||||
|
||||
const branches: BranchInfo[] = branchesOutput
|
||||
.trim()
|
||||
@@ -68,18 +67,15 @@ export function createListBranchesHandler() {
|
||||
try {
|
||||
// Fetch latest remote refs (silently, don't fail if offline)
|
||||
try {
|
||||
await execAsync('git fetch --all --quiet', {
|
||||
cwd: worktreePath,
|
||||
timeout: 10000, // 10 second timeout
|
||||
});
|
||||
await execGitCommand(['fetch', '--all', '--quiet'], worktreePath);
|
||||
} catch {
|
||||
// Ignore fetch errors - we'll use cached remote refs
|
||||
}
|
||||
|
||||
// List remote branches
|
||||
const { stdout: remoteBranchesOutput } = await execAsync(
|
||||
'git branch -r --format="%(refname:short)"',
|
||||
{ cwd: worktreePath }
|
||||
const remoteBranchesOutput = await execGitCommand(
|
||||
['branch', '-r', '--format=%(refname:short)'],
|
||||
worktreePath
|
||||
);
|
||||
|
||||
const localBranchNames = new Set(branches.map((b) => b.name));
|
||||
@@ -118,9 +114,7 @@ export function createListBranchesHandler() {
|
||||
// Check if any remotes are configured for this repository
|
||||
let hasAnyRemotes = false;
|
||||
try {
|
||||
const { stdout: remotesOutput } = await execAsync('git remote', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const remotesOutput = await execGitCommand(['remote'], worktreePath);
|
||||
hasAnyRemotes = remotesOutput.trim().length > 0;
|
||||
} catch {
|
||||
// If git remote fails, assume no remotes
|
||||
|
||||
@@ -13,7 +13,14 @@ import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
normalizePath,
|
||||
execEnv,
|
||||
isGhCliAvailable,
|
||||
execGitCommand,
|
||||
} from '../common.js';
|
||||
import {
|
||||
readAllWorktreeMetadata,
|
||||
updateWorktreePRInfo,
|
||||
@@ -29,6 +36,22 @@ import {
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
/** True when git (or shell) could not be spawned (e.g. ENOENT in sandboxed CI). */
|
||||
function isSpawnENOENT(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const e = error as { code?: string; errno?: number; syscall?: string };
|
||||
// Accept ENOENT with or without syscall so wrapped/reexported errors are handled.
|
||||
// Node may set syscall to 'spawn' or 'spawn git' (or other command name).
|
||||
if (e.code === 'ENOENT' || e.errno === -2) {
|
||||
return (
|
||||
e.syscall === 'spawn' ||
|
||||
(typeof e.syscall === 'string' && e.syscall.startsWith('spawn')) ||
|
||||
e.syscall === undefined
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for GitHub remote status per project path.
|
||||
* This prevents repeated "no git remotes found" warnings when polling
|
||||
@@ -64,6 +87,8 @@ interface WorktreeInfo {
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
/** List of files with conflicts */
|
||||
conflictFiles?: string[];
|
||||
/** Source branch involved in merge/rebase/cherry-pick, when resolvable */
|
||||
conflictSourceBranch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,13 +100,11 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
hasConflicts: boolean;
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
conflictFiles?: string[];
|
||||
conflictSourceBranch?: string;
|
||||
}> {
|
||||
try {
|
||||
// Find the canonical .git directory for this worktree
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
timeout: 15000,
|
||||
});
|
||||
// Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI)
|
||||
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
// Check for merge, rebase, and cherry-pick state files/directories
|
||||
@@ -121,10 +144,10 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
// Get list of conflicted files using machine-readable git status
|
||||
let conflictFiles: string[] = [];
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
|
||||
cwd: worktreePath,
|
||||
timeout: 15000,
|
||||
});
|
||||
const statusOutput = await execGitCommand(
|
||||
['diff', '--name-only', '--diff-filter=U'],
|
||||
worktreePath
|
||||
);
|
||||
conflictFiles = statusOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
@@ -133,10 +156,84 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
// Fall back to empty list if diff fails
|
||||
}
|
||||
|
||||
// Detect the source branch involved in the conflict
|
||||
let conflictSourceBranch: string | undefined;
|
||||
try {
|
||||
if (conflictType === 'merge' && mergeHeadExists) {
|
||||
// For merges, resolve MERGE_HEAD to a branch name
|
||||
const mergeHead = (
|
||||
(await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string
|
||||
).trim();
|
||||
try {
|
||||
const branchName = await execGitCommand(
|
||||
['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead],
|
||||
worktreePath
|
||||
);
|
||||
const cleaned = branchName.trim().replace(/~\d+$/, '');
|
||||
if (cleaned && cleaned !== 'undefined') {
|
||||
conflictSourceBranch = cleaned;
|
||||
}
|
||||
} catch {
|
||||
// Could not resolve to branch name
|
||||
}
|
||||
} else if (conflictType === 'rebase') {
|
||||
// For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name
|
||||
const headNamePath = rebaseMergeExists
|
||||
? path.join(gitDir, 'rebase-merge', 'onto-name')
|
||||
: path.join(gitDir, 'rebase-apply', 'onto-name');
|
||||
try {
|
||||
const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
|
||||
if (ontoName) {
|
||||
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
|
||||
}
|
||||
} catch {
|
||||
// onto-name may not exist; try to resolve the onto commit
|
||||
try {
|
||||
const ontoPath = rebaseMergeExists
|
||||
? path.join(gitDir, 'rebase-merge', 'onto')
|
||||
: path.join(gitDir, 'rebase-apply', 'onto');
|
||||
const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim();
|
||||
if (ontoCommit) {
|
||||
const branchName = await execGitCommand(
|
||||
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
|
||||
worktreePath
|
||||
);
|
||||
const cleaned = branchName.trim().replace(/~\d+$/, '');
|
||||
if (cleaned && cleaned !== 'undefined') {
|
||||
conflictSourceBranch = cleaned;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Could not resolve onto commit
|
||||
}
|
||||
}
|
||||
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
|
||||
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
|
||||
const cherryPickHead = (
|
||||
(await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
|
||||
).trim();
|
||||
try {
|
||||
const branchName = await execGitCommand(
|
||||
['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead],
|
||||
worktreePath
|
||||
);
|
||||
const cleaned = branchName.trim().replace(/~\d+$/, '');
|
||||
if (cleaned && cleaned !== 'undefined') {
|
||||
conflictSourceBranch = cleaned;
|
||||
}
|
||||
} catch {
|
||||
// Could not resolve to branch name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore source branch detection errors
|
||||
}
|
||||
|
||||
return {
|
||||
hasConflicts: conflictFiles.length > 0,
|
||||
conflictType,
|
||||
conflictFiles,
|
||||
conflictSourceBranch,
|
||||
};
|
||||
} catch {
|
||||
// If anything fails, assume no conflicts
|
||||
@@ -146,13 +243,69 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch --show-current', { cwd });
|
||||
const stdout = await execGitCommand(['branch', '--show-current'], cwd);
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBranchFromHeadRef(headRef: string): string | null {
|
||||
let normalized = headRef.trim();
|
||||
const prefixes = ['refs/heads/', 'refs/remotes/origin/', 'refs/remotes/', 'refs/'];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
normalized = normalized.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the full branch name, including any slashes (e.g., "feature/my-branch")
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover the branch name for a worktree in detached HEAD state.
|
||||
* This happens during rebase operations where git detaches HEAD from the branch.
|
||||
* We look at git state files (rebase-merge/head-name, rebase-apply/head-name)
|
||||
* to determine which branch the operation is targeting.
|
||||
*
|
||||
* Note: merge conflicts do NOT detach HEAD, so `git worktree list --porcelain`
|
||||
* still includes the `branch` line for merge conflicts. This recovery is
|
||||
* specifically for rebase and cherry-pick operations.
|
||||
*/
|
||||
async function recoverBranchForDetachedWorktree(worktreePath: string): Promise<string | null> {
|
||||
try {
|
||||
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
// During a rebase, the original branch is stored in rebase-merge/head-name
|
||||
try {
|
||||
const headNamePath = path.join(gitDir, 'rebase-merge', 'head-name');
|
||||
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
|
||||
const branch = normalizeBranchFromHeadRef(headName);
|
||||
if (branch) return branch;
|
||||
} catch {
|
||||
// Not a rebase-merge
|
||||
}
|
||||
|
||||
// rebase-apply also stores the original branch in head-name
|
||||
try {
|
||||
const headNamePath = path.join(gitDir, 'rebase-apply', 'head-name');
|
||||
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
|
||||
const branch = normalizeBranchFromHeadRef(headName);
|
||||
if (branch) return branch;
|
||||
} catch {
|
||||
// Not a rebase-apply
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the .worktrees directory to discover worktrees that may exist on disk
|
||||
* but are not registered with git (e.g., created externally or corrupted state).
|
||||
@@ -204,22 +357,36 @@ async function scanWorktreesDirectory(
|
||||
});
|
||||
} else {
|
||||
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
|
||||
let headBranch: string | null = null;
|
||||
try {
|
||||
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const headBranch = headRef.trim();
|
||||
if (headBranch && headBranch !== 'HEAD') {
|
||||
logger.info(
|
||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||
);
|
||||
discovered.push({
|
||||
path: normalizedPath,
|
||||
branch: headBranch,
|
||||
});
|
||||
const headRef = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
worktreePath
|
||||
);
|
||||
const ref = headRef.trim();
|
||||
if (ref && ref !== 'HEAD') {
|
||||
headBranch = ref;
|
||||
}
|
||||
} catch {
|
||||
// Can't determine branch, skip this directory
|
||||
} catch (error) {
|
||||
// Can't determine branch from HEAD ref (including timeout) - fall back to detached HEAD recovery
|
||||
logger.debug(
|
||||
`Failed to resolve HEAD ref for ${worktreePath}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// If HEAD is detached (rebase/merge in progress), try recovery from git state files
|
||||
if (!headBranch) {
|
||||
headBranch = await recoverBranchForDetachedWorktree(worktreePath);
|
||||
}
|
||||
|
||||
if (headBranch) {
|
||||
logger.info(
|
||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||
);
|
||||
discovered.push({
|
||||
path: normalizedPath,
|
||||
branch: headBranch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,15 +545,14 @@ export function createListHandler() {
|
||||
// Get current branch in main directory
|
||||
const currentBranch = await getCurrentBranch(projectPath);
|
||||
|
||||
// Get actual worktrees from git
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Get actual worktrees from git (execGitCommand avoids /bin/sh in sandboxed CI)
|
||||
const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
|
||||
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
const removedWorktrees: Array<{ path: string; branch: string }> = [];
|
||||
let hasMissingWorktree = false;
|
||||
const lines = stdout.split('\n');
|
||||
let current: { path?: string; branch?: string } = {};
|
||||
let current: { path?: string; branch?: string; isDetached?: boolean } = {};
|
||||
let isFirst = true;
|
||||
|
||||
// First pass: detect removed worktrees
|
||||
@@ -395,8 +561,11 @@ export function createListHandler() {
|
||||
current.path = normalizePath(line.slice(9));
|
||||
} else if (line.startsWith('branch ')) {
|
||||
current.branch = line.slice(7).replace('refs/heads/', '');
|
||||
} else if (line.startsWith('detached')) {
|
||||
// Worktree is in detached HEAD state (e.g., during rebase)
|
||||
current.isDetached = true;
|
||||
} else if (line === '') {
|
||||
if (current.path && current.branch) {
|
||||
if (current.path) {
|
||||
const isMainWorktree = isFirst;
|
||||
// Check if the worktree directory actually exists
|
||||
// Skip checking/pruning the main worktree (projectPath itself)
|
||||
@@ -407,14 +576,19 @@ export function createListHandler() {
|
||||
} catch {
|
||||
worktreeExists = false;
|
||||
}
|
||||
|
||||
if (!isMainWorktree && !worktreeExists) {
|
||||
hasMissingWorktree = true;
|
||||
// Worktree directory doesn't exist - it was manually deleted
|
||||
removedWorktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
});
|
||||
} else {
|
||||
// Worktree exists (or is main worktree), add it to the list
|
||||
// Only add to removed list if we know the branch name
|
||||
if (current.branch) {
|
||||
removedWorktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
});
|
||||
}
|
||||
} else if (current.branch) {
|
||||
// Normal case: worktree with a known branch
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
@@ -423,16 +597,29 @@ export function createListHandler() {
|
||||
hasWorktree: true,
|
||||
});
|
||||
isFirst = false;
|
||||
} else if (current.isDetached && worktreeExists) {
|
||||
// Detached HEAD (e.g., rebase in progress) - try to recover branch name.
|
||||
// This is critical: without this, worktrees undergoing rebase/merge
|
||||
// operations would silently disappear from the UI.
|
||||
const recoveredBranch = await recoverBranchForDetachedWorktree(current.path);
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: recoveredBranch || `(detached)`,
|
||||
isMain: isMainWorktree,
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
});
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Prune removed worktrees from git (only if any were detected)
|
||||
if (removedWorktrees.length > 0) {
|
||||
// Prune removed worktrees from git (only if any missing worktrees were detected)
|
||||
if (hasMissingWorktree) {
|
||||
try {
|
||||
await execAsync('git worktree prune', { cwd: projectPath });
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
} catch {
|
||||
// Prune failed, but we'll still report the removed worktrees
|
||||
}
|
||||
@@ -461,9 +648,7 @@ export function createListHandler() {
|
||||
if (includeDetails) {
|
||||
for (const worktree of worktrees) {
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
cwd: worktree.path,
|
||||
});
|
||||
const statusOutput = await execGitCommand(['status', '--porcelain'], worktree.path);
|
||||
const changedFiles = statusOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
@@ -486,13 +671,14 @@ export function createListHandler() {
|
||||
// hasConflicts is true only when there are actual unresolved files
|
||||
worktree.hasConflicts = conflictState.hasConflicts;
|
||||
worktree.conflictFiles = conflictState.conflictFiles;
|
||||
worktree.conflictSourceBranch = conflictState.conflictSourceBranch;
|
||||
} catch {
|
||||
// Ignore conflict detection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
|
||||
// Assign PR info to each worktree.
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||
const githubPRs = includeDetails
|
||||
@@ -510,14 +696,27 @@ export function createListHandler() {
|
||||
const metadata = allMetadata.get(worktree.branch);
|
||||
const githubPR = githubPRs.get(worktree.branch);
|
||||
|
||||
if (githubPR) {
|
||||
// Prefer fresh GitHub data (it has the current state)
|
||||
const metadataPR = metadata?.pr;
|
||||
// Preserve explicit user-selected PR tracking from metadata when it differs
|
||||
// from branch-derived GitHub PR lookup. This allows "Change PR Number" to
|
||||
// persist instead of being overwritten by gh pr list for the branch.
|
||||
const hasManualOverride =
|
||||
!!metadataPR && !!githubPR && metadataPR.number !== githubPR.number;
|
||||
|
||||
if (hasManualOverride) {
|
||||
worktree.pr = metadataPR;
|
||||
} else if (githubPR) {
|
||||
// Use fresh GitHub data when there is no explicit override.
|
||||
worktree.pr = githubPR;
|
||||
|
||||
// Sync metadata with GitHub state when:
|
||||
// 1. No metadata exists for this PR (PR created externally)
|
||||
// 2. State has changed (e.g., merged/closed on GitHub)
|
||||
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
|
||||
// Sync metadata when missing or stale so fallback data stays current.
|
||||
const needsSync =
|
||||
!metadataPR ||
|
||||
metadataPR.number !== githubPR.number ||
|
||||
metadataPR.state !== githubPR.state ||
|
||||
metadataPR.title !== githubPR.title ||
|
||||
metadataPR.url !== githubPR.url ||
|
||||
metadataPR.createdAt !== githubPR.createdAt;
|
||||
if (needsSync) {
|
||||
// Fire and forget - don't block the response
|
||||
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
|
||||
@@ -526,9 +725,9 @@ export function createListHandler() {
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
|
||||
} else if (metadataPR && metadataPR.state === 'OPEN') {
|
||||
// Fall back to stored metadata only if the PR is still OPEN
|
||||
worktree.pr = metadata.pr;
|
||||
worktree.pr = metadataPR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +737,26 @@ export function createListHandler() {
|
||||
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
// When git is unavailable (e.g. sandboxed E2E, PATH without git), return minimal list so UI still loads
|
||||
if (isSpawnENOENT(error)) {
|
||||
const projectPathFromBody = (req.body as { projectPath?: string })?.projectPath;
|
||||
const mainPath = projectPathFromBody ? normalizePath(projectPathFromBody) : undefined;
|
||||
if (mainPath) {
|
||||
res.json({
|
||||
success: true,
|
||||
worktrees: [
|
||||
{
|
||||
path: mainPath,
|
||||
branch: 'main',
|
||||
isMain: true,
|
||||
isCurrent: true,
|
||||
hasWorktree: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
logError(error, 'List worktrees failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ import type { PullResult } from '../../../services/pull-service.js';
|
||||
export function createPullHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, remote, stashIfNeeded } = req.body as {
|
||||
const { worktreePath, remote, remoteBranch, stashIfNeeded } = req.body as {
|
||||
worktreePath: string;
|
||||
remote?: string;
|
||||
/** Specific remote branch to pull (e.g. 'main'). When provided, pulls this branch from the remote regardless of tracking config. */
|
||||
remoteBranch?: string;
|
||||
/** When true, automatically stash local changes before pulling and reapply after */
|
||||
stashIfNeeded?: boolean;
|
||||
};
|
||||
@@ -39,7 +41,7 @@ export function createPullHandler() {
|
||||
}
|
||||
|
||||
// Execute the pull via the service
|
||||
const result = await performPull(worktreePath, { remote, stashIfNeeded });
|
||||
const result = await performPull(worktreePath, { remote, remoteBranch, stashIfNeeded });
|
||||
|
||||
// Map service result to HTTP response
|
||||
mapResultToResponse(res, result);
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface AgentExecutionOptions {
|
||||
specAlreadyDetected?: boolean;
|
||||
existingApprovedPlanContent?: string;
|
||||
persistedTasks?: ParsedTask[];
|
||||
/** Feature status - used to check if pipeline summary extraction is required */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import path from 'path';
|
||||
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
@@ -91,6 +92,7 @@ export class AgentExecutor {
|
||||
existingApprovedPlanContent,
|
||||
persistedTasks,
|
||||
credentials,
|
||||
status, // Feature status for pipeline summary check
|
||||
claudeCompatibleProvider,
|
||||
mcpServers,
|
||||
sdkSessionId,
|
||||
@@ -207,6 +209,17 @@ export class AgentExecutor {
|
||||
if (writeTimeout) clearTimeout(writeTimeout);
|
||||
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
||||
await writeToFile();
|
||||
|
||||
// Extract and save summary from the new content generated in this session
|
||||
await this.extractAndSaveSessionSummary(
|
||||
projectPath,
|
||||
featureId,
|
||||
result.responseText,
|
||||
previousContent,
|
||||
callbacks,
|
||||
status
|
||||
);
|
||||
|
||||
return {
|
||||
responseText: result.responseText,
|
||||
specDetected: true,
|
||||
@@ -340,9 +353,78 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop
|
||||
// or if we're in a simple execution mode (planningMode='skip')
|
||||
await this.extractAndSaveSessionSummary(
|
||||
projectPath,
|
||||
featureId,
|
||||
responseText,
|
||||
previousContent,
|
||||
callbacks,
|
||||
status
|
||||
);
|
||||
|
||||
return { responseText, specDetected, tasksCompleted, aborted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the follow-up session scaffold marker from content.
|
||||
* The scaffold is added when resuming a session with previous content:
|
||||
* "\n\n---\n\n## Follow-up Session\n\n"
|
||||
* This ensures fallback summaries don't include the scaffold header.
|
||||
*
|
||||
* The regex pattern handles variations in whitespace while matching the
|
||||
* scaffold structure: dashes followed by "## Follow-up Session" at the
|
||||
* start of the content.
|
||||
*/
|
||||
private static stripFollowUpScaffold(content: string): string {
|
||||
// Pattern matches: ^\s*---\s*##\s*Follow-up Session\s*
|
||||
// - ^ = start of content (scaffold is always at the beginning of sessionContent)
|
||||
// - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers)
|
||||
// - --- = literal dashes
|
||||
// - \s* = whitespace between dashes and heading
|
||||
// - ## = heading marker
|
||||
// - \s* = whitespace before "Follow-up"
|
||||
// - Follow-up Session = literal heading text
|
||||
// - \s* = trailing whitespace/newlines after heading
|
||||
const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/;
|
||||
return content.replace(scaffoldPattern, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract summary ONLY from the new content generated in this session
|
||||
* and save it via the provided callback.
|
||||
*/
|
||||
private async extractAndSaveSessionSummary(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
responseText: string,
|
||||
previousContent: string | undefined,
|
||||
callbacks: AgentExecutorCallbacks,
|
||||
status?: string
|
||||
): Promise<void> {
|
||||
const sessionContent = responseText.substring(previousContent ? previousContent.length : 0);
|
||||
const summary = extractSummary(sessionContent);
|
||||
if (summary) {
|
||||
await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails.
|
||||
if (isPipelineStatus(status)) {
|
||||
// Strip any follow-up session scaffold before using as fallback
|
||||
const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent);
|
||||
const fallback = cleanSessionContent.trim();
|
||||
if (fallback) {
|
||||
await callbacks.saveFeatureSummary(projectPath, featureId, fallback);
|
||||
}
|
||||
logger.warn(
|
||||
`[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTasksLoop(
|
||||
options: AgentExecutionOptions,
|
||||
tasks: ParsedTask[],
|
||||
@@ -439,14 +521,15 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
if (!taskCompleteDetected) {
|
||||
const cid = detectTaskCompleteMarker(taskOutput);
|
||||
if (cid) {
|
||||
const completeMarker = detectTaskCompleteMarker(taskOutput);
|
||||
if (completeMarker) {
|
||||
taskCompleteDetected = true;
|
||||
await this.featureStateManager.updateTaskStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
cid,
|
||||
'completed'
|
||||
completeMarker.id,
|
||||
'completed',
|
||||
completeMarker.summary
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -524,8 +607,6 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
const summary = extractSummary(responseText);
|
||||
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return { responseText, tasksCompleted, aborted: false };
|
||||
}
|
||||
|
||||
@@ -722,8 +803,6 @@ export class AgentExecutor {
|
||||
);
|
||||
responseText = r.responseText;
|
||||
}
|
||||
const summary = extractSummary(responseText);
|
||||
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return { responseText, tasksCompleted };
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
||||
const FAILURE_WINDOW_MS = 60000;
|
||||
|
||||
// Sleep intervals for the auto-loop (in milliseconds)
|
||||
const SLEEP_INTERVAL_CAPACITY_MS = 5000;
|
||||
const SLEEP_INTERVAL_IDLE_MS = 10000;
|
||||
const SLEEP_INTERVAL_NORMAL_MS = 2000;
|
||||
const SLEEP_INTERVAL_ERROR_MS = 5000;
|
||||
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
@@ -169,20 +175,32 @@ export class AutoLoopCoordinator {
|
||||
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
|
||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
if (runningCount >= projectState.config.maxConcurrency) {
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
||||
if (pendingFeatures.length === 0) {
|
||||
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
// Double-check that we have no features in 'in_progress' state that might
|
||||
// have been released from the concurrency manager but not yet updated to
|
||||
// their final status. This prevents auto_mode_idle from firing prematurely
|
||||
// when features are transitioning states (e.g., during status update).
|
||||
const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree(
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
branchName
|
||||
);
|
||||
|
||||
// Only emit auto_mode_idle if we're truly done with all features
|
||||
if (!hasInProgressFeatures) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
}
|
||||
}
|
||||
await this.sleep(10000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -228,10 +246,10 @@ export class AutoLoopCoordinator {
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.sleep(2000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal);
|
||||
} catch {
|
||||
if (projectState.abortController.signal.aborted) break;
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal);
|
||||
}
|
||||
}
|
||||
projectState.isRunning = false;
|
||||
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature belongs to the current worktree based on branch name.
|
||||
* For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'.
|
||||
* For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName.
|
||||
*/
|
||||
private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean {
|
||||
const isMainWorktree = branchName === null || branchName === 'main';
|
||||
if (isMainWorktree) {
|
||||
// Main worktree: include features with no branchName or branchName === 'main'
|
||||
return !feature.branchName || feature.branchName === 'main';
|
||||
} else {
|
||||
// Feature worktree: only include exact branch match
|
||||
return feature.branchName === branchName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are features in 'in_progress' status for the current worktree.
|
||||
* This prevents auto_mode_idle from firing prematurely when features are
|
||||
* transitioning states (e.g., during status update from in_progress to completed).
|
||||
*/
|
||||
private async hasInProgressFeaturesForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<boolean> {
|
||||
if (!this.loadAllFeaturesFn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFeatures = await this.loadAllFeaturesFn(projectPath);
|
||||
return allFeatures.some(
|
||||
(f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName)
|
||||
);
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
logger.warn(
|
||||
`Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,9 +232,10 @@ export class AutoModeServiceCompat {
|
||||
}
|
||||
|
||||
async detectOrphanedFeatures(
|
||||
projectPath: string
|
||||
projectPath: string,
|
||||
preloadedFeatures?: Feature[]
|
||||
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.detectOrphanedFeatures();
|
||||
return facade.detectOrphanedFeatures(preloadedFeatures);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||
import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
DEFAULT_MODELS,
|
||||
stripProviderPrefix,
|
||||
isPipelineStatus,
|
||||
} from '@automaker/types';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
@@ -23,7 +28,7 @@ import * as secureFs from '../../lib/secure-fs.js';
|
||||
import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getProviderByModelId,
|
||||
resolveProviderContext,
|
||||
getMCPServersFromSettings,
|
||||
getDefaultMaxTurnsSetting,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
@@ -79,6 +84,37 @@ export class AutoModeServiceFacade {
|
||||
private readonly settingsService: SettingsService | null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Determine if a feature is eligible to be picked up by the auto-mode loop.
|
||||
*
|
||||
* @param feature - The feature to check
|
||||
* @param branchName - The current worktree branch name (null for main)
|
||||
* @param primaryBranch - The resolved primary branch name for the project
|
||||
* @returns True if the feature is eligible for auto-dispatch
|
||||
*/
|
||||
public static isFeatureEligibleForAutoMode(
|
||||
feature: Feature,
|
||||
branchName: string | null,
|
||||
primaryBranch: string | null
|
||||
): boolean {
|
||||
const isEligibleStatus =
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted' ||
|
||||
isPipelineStatus(feature.status);
|
||||
|
||||
if (!isEligibleStatus) return false;
|
||||
|
||||
// Filter by branch/worktree alignment
|
||||
if (branchName === null) {
|
||||
// For main worktree, include features with no branch or matching primary branch
|
||||
return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch);
|
||||
} else {
|
||||
// For named worktrees, only include features matching that branch
|
||||
return feature.branchName === branchName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify and log an error at the facade boundary.
|
||||
* Emits an error event to the UI so failures are surfaced to the user.
|
||||
@@ -190,8 +226,7 @@ export class AutoModeServiceFacade {
|
||||
/**
|
||||
* Shared agent-run helper used by both PipelineOrchestrator and ExecutionService.
|
||||
*
|
||||
* Resolves the model string, looks up the custom provider/credentials via
|
||||
* getProviderByModelId, then delegates to agentExecutor.execute with the
|
||||
* Resolves provider/model context, then delegates to agentExecutor.execute with the
|
||||
* full payload. The opts parameter uses an index-signature union so it
|
||||
* accepts both the typed ExecutionService opts object and the looser
|
||||
* Record<string, unknown> used by PipelineOrchestrator without requiring
|
||||
@@ -217,6 +252,7 @@ export class AutoModeServiceFacade {
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
branchName?: string | null;
|
||||
status?: string; // Feature status for pipeline summary check
|
||||
[key: string]: unknown;
|
||||
}
|
||||
): Promise<void> => {
|
||||
@@ -229,16 +265,19 @@ export class AutoModeServiceFacade {
|
||||
| import('@automaker/types').ClaudeCompatibleProvider
|
||||
| undefined;
|
||||
let credentials: import('@automaker/types').Credentials | undefined;
|
||||
let providerResolvedModel: string | undefined;
|
||||
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
resolvedModel,
|
||||
const providerId = opts?.providerId as string | undefined;
|
||||
const result = await resolveProviderContext(
|
||||
settingsService,
|
||||
resolvedModel,
|
||||
providerId,
|
||||
'[AutoModeFacade]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
credentials = providerResult.credentials;
|
||||
}
|
||||
claudeCompatibleProvider = result.provider;
|
||||
credentials = result.credentials;
|
||||
providerResolvedModel = result.resolvedModel;
|
||||
}
|
||||
|
||||
// Build sdkOptions with proper maxTurns and allowedTools for auto-mode.
|
||||
@@ -264,7 +303,7 @@ export class AutoModeServiceFacade {
|
||||
|
||||
const sdkOpts = createAutoModeOptions({
|
||||
cwd: workDir,
|
||||
model: resolvedModel,
|
||||
model: providerResolvedModel || resolvedModel,
|
||||
systemPrompt: opts?.systemPrompt,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
@@ -276,8 +315,14 @@ export class AutoModeServiceFacade {
|
||||
| undefined,
|
||||
});
|
||||
|
||||
if (!sdkOpts) {
|
||||
logger.error(
|
||||
`[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` +
|
||||
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` +
|
||||
`maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` +
|
||||
`provider=${provider.getName()}`
|
||||
);
|
||||
@@ -300,6 +345,7 @@ export class AutoModeServiceFacade {
|
||||
thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined,
|
||||
reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined,
|
||||
branchName: opts?.branchName as string | null | undefined,
|
||||
status: opts?.status as string | undefined,
|
||||
provider,
|
||||
effectiveBareModel,
|
||||
credentials,
|
||||
@@ -373,12 +419,8 @@ export class AutoModeServiceFacade {
|
||||
if (branchName === null) {
|
||||
primaryBranch = await worktreeResolver.getCurrentBranch(pPath);
|
||||
}
|
||||
return features.filter(
|
||||
(f) =>
|
||||
(f.status === 'backlog' || f.status === 'ready') &&
|
||||
(branchName === null
|
||||
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
|
||||
: f.branchName === branchName)
|
||||
return features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch)
|
||||
);
|
||||
},
|
||||
(pPath, branchName, maxConcurrency) =>
|
||||
@@ -421,9 +463,25 @@ export class AutoModeServiceFacade {
|
||||
(pPath, featureId, status) =>
|
||||
featureStateManager.updateFeatureStatus(pPath, featureId, status),
|
||||
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
|
||||
async (_feature) => {
|
||||
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService
|
||||
return '';
|
||||
async (feature) => {
|
||||
// getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode
|
||||
if (!feature.planningMode || feature.planningMode === 'skip') {
|
||||
return '';
|
||||
}
|
||||
const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]');
|
||||
const autoModePrompts = prompts.autoMode;
|
||||
switch (feature.planningMode) {
|
||||
case 'lite':
|
||||
return feature.requirePlanApproval
|
||||
? autoModePrompts.planningLiteWithApproval + '\n\n'
|
||||
: autoModePrompts.planningLite + '\n\n';
|
||||
case 'spec':
|
||||
return autoModePrompts.planningSpec + '\n\n';
|
||||
case 'full':
|
||||
return autoModePrompts.planningFull + '\n\n';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
(pPath, featureId, summary) =>
|
||||
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
|
||||
@@ -1075,12 +1133,13 @@ export class AutoModeServiceFacade {
|
||||
|
||||
/**
|
||||
* Detect orphaned features (features with missing branches)
|
||||
* @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads
|
||||
*/
|
||||
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> {
|
||||
async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise<OrphanedFeatureInfo[]> {
|
||||
const orphanedFeatures: OrphanedFeatureInfo[] = [];
|
||||
|
||||
try {
|
||||
const allFeatures = await this.featureLoader.getAll(this.projectPath);
|
||||
const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath));
|
||||
const featuresWithBranches = allFeatures.filter(
|
||||
(f) => f.branchName && f.branchName.trim() !== ''
|
||||
);
|
||||
|
||||
@@ -193,7 +193,11 @@ export class CodexModelCacheService {
|
||||
* Infer tier from model ID
|
||||
*/
|
||||
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
|
||||
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
|
||||
if (
|
||||
modelId.includes('max') ||
|
||||
modelId.includes('gpt-5.2-codex') ||
|
||||
modelId.includes('gpt-5.3-codex')
|
||||
) {
|
||||
return 'premium';
|
||||
}
|
||||
if (modelId.includes('mini')) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import path from 'path';
|
||||
import net from 'net';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import fs from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
|
||||
const logger = createLogger('DevServerService');
|
||||
|
||||
@@ -86,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
||||
},
|
||||
];
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load.
|
||||
// 100ms (~10fps) is sufficient for readable log streaming while keeping
|
||||
// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated
|
||||
// up to 250 events/sec which caused progressive browser slowdown from
|
||||
// accumulated console logs, JSON serialization overhead, and React re-renders.
|
||||
const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate
|
||||
const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
@@ -110,6 +116,21 @@ export interface DevServerInfo {
|
||||
urlDetected: boolean;
|
||||
// Timer for URL detection timeout fallback
|
||||
urlDetectionTimeout: NodeJS.Timeout | null;
|
||||
// Custom command used to start the server
|
||||
customCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistable subset of DevServerInfo for survival across server restarts
|
||||
*/
|
||||
interface PersistedDevServerInfo {
|
||||
worktreePath: string;
|
||||
allocatedPort: number;
|
||||
port: number;
|
||||
url: string;
|
||||
startedAt: string;
|
||||
urlDetected: boolean;
|
||||
customCommand?: string;
|
||||
}
|
||||
|
||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||
@@ -121,8 +142,20 @@ const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
|
||||
|
||||
class DevServerService {
|
||||
private runningServers: Map<string, DevServerInfo> = new Map();
|
||||
private startingServers: Set<string> = new Set();
|
||||
private allocatedPorts: Set<number> = new Set();
|
||||
private emitter: EventEmitter | null = null;
|
||||
private dataDir: string | null = null;
|
||||
private saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Initialize the service with data directory for persistence
|
||||
*/
|
||||
async initialize(dataDir: string, emitter: EventEmitter): Promise<void> {
|
||||
this.dataDir = dataDir;
|
||||
this.emitter = emitter;
|
||||
await this.loadState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the event emitter for streaming log events
|
||||
@@ -132,6 +165,131 @@ class DevServerService {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state of running servers to disk
|
||||
*/
|
||||
private async saveState(): Promise<void> {
|
||||
if (!this.dataDir) return;
|
||||
|
||||
// Queue the save operation to prevent concurrent writes
|
||||
this.saveQueue = this.saveQueue
|
||||
.then(async () => {
|
||||
if (!this.dataDir) return;
|
||||
try {
|
||||
const statePath = path.join(this.dataDir, 'dev-servers.json');
|
||||
const persistedInfo: PersistedDevServerInfo[] = Array.from(
|
||||
this.runningServers.values()
|
||||
).map((s) => ({
|
||||
worktreePath: s.worktreePath,
|
||||
allocatedPort: s.allocatedPort,
|
||||
port: s.port,
|
||||
url: s.url,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
urlDetected: s.urlDetected,
|
||||
customCommand: s.customCommand,
|
||||
}));
|
||||
|
||||
await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2));
|
||||
logger.debug(`Saved dev server state to ${statePath}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save dev server state:', error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error in save queue:', error);
|
||||
});
|
||||
|
||||
return this.saveQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the state of running servers from disk
|
||||
*/
|
||||
private async loadState(): Promise<void> {
|
||||
if (!this.dataDir) return;
|
||||
|
||||
try {
|
||||
const statePath = path.join(this.dataDir, 'dev-servers.json');
|
||||
try {
|
||||
await fs.access(statePath, constants.F_OK);
|
||||
} catch {
|
||||
// File doesn't exist, which is fine
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(statePath, 'utf-8');
|
||||
const rawParsed: unknown = JSON.parse(content);
|
||||
|
||||
if (!Array.isArray(rawParsed)) {
|
||||
logger.warn('Dev server state file is not an array, skipping load');
|
||||
return;
|
||||
}
|
||||
|
||||
const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => {
|
||||
if (entry === null || typeof entry !== 'object') {
|
||||
logger.warn('Dropping invalid dev server entry (not an object):', entry);
|
||||
return false;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
const valid =
|
||||
typeof e.worktreePath === 'string' &&
|
||||
e.worktreePath.length > 0 &&
|
||||
typeof e.allocatedPort === 'number' &&
|
||||
Number.isInteger(e.allocatedPort) &&
|
||||
e.allocatedPort >= 1 &&
|
||||
e.allocatedPort <= 65535 &&
|
||||
typeof e.port === 'number' &&
|
||||
Number.isInteger(e.port) &&
|
||||
e.port >= 1 &&
|
||||
e.port <= 65535 &&
|
||||
typeof e.url === 'string' &&
|
||||
typeof e.startedAt === 'string' &&
|
||||
typeof e.urlDetected === 'boolean' &&
|
||||
(e.customCommand === undefined || typeof e.customCommand === 'string');
|
||||
if (!valid) {
|
||||
logger.warn('Dropping malformed dev server entry:', e);
|
||||
}
|
||||
return valid;
|
||||
}) as PersistedDevServerInfo[];
|
||||
|
||||
logger.info(`Loading ${persistedInfo.length} dev servers from state`);
|
||||
|
||||
for (const info of persistedInfo) {
|
||||
// Check if the process is still running on the port
|
||||
// Since we can't reliably re-attach to the process for output,
|
||||
// we'll just check if the port is in use.
|
||||
const portInUse = !(await this.isPortAvailable(info.port));
|
||||
|
||||
if (portInUse) {
|
||||
logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`);
|
||||
const serverInfo: DevServerInfo = {
|
||||
...info,
|
||||
startedAt: new Date(info.startedAt),
|
||||
process: null, // Process object is lost, but we know it's running
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetectionTimeout: null,
|
||||
};
|
||||
this.runningServers.set(info.worktreePath, serverInfo);
|
||||
this.allocatedPorts.add(info.allocatedPort);
|
||||
} else {
|
||||
logger.info(
|
||||
`Dev server on port ${info.port} for ${info.worktreePath} is no longer running`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup stale entries from the file if any
|
||||
if (this.runningServers.size !== persistedInfo.length) {
|
||||
await this.saveState();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load dev server state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune a stale server entry whose process has exited without cleanup.
|
||||
* Clears any pending timers, removes the port from allocatedPorts, deletes
|
||||
@@ -148,6 +306,10 @@ class DevServerService {
|
||||
// been mutated by detectUrlFromOutput to reflect the actual detected port.
|
||||
this.allocatedPorts.delete(server.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
|
||||
// Persist state change
|
||||
this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err));
|
||||
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
@@ -249,7 +411,7 @@ class DevServerService {
|
||||
* - PHP: "Development Server (http://localhost:8000) started"
|
||||
* - Generic: Any localhost URL with a port
|
||||
*/
|
||||
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
||||
private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise<void> {
|
||||
// Skip if URL already detected
|
||||
if (server.urlDetected) {
|
||||
return;
|
||||
@@ -304,6 +466,11 @@ class DevServerService {
|
||||
|
||||
logger.info(`Detected server URL via ${description}: ${detectedUrl}`);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in detectUrlFromOutput:', err)
|
||||
);
|
||||
|
||||
// Emit URL update event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
@@ -346,6 +513,11 @@ class DevServerService {
|
||||
|
||||
logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err)
|
||||
);
|
||||
|
||||
// Emit URL update event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
@@ -365,7 +537,7 @@ class DevServerService {
|
||||
* Handle incoming stdout/stderr data from dev server process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
*/
|
||||
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
|
||||
private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise<void> {
|
||||
// Skip output if server is stopping
|
||||
if (server.stopping) {
|
||||
return;
|
||||
@@ -374,7 +546,7 @@ class DevServerService {
|
||||
const content = data.toString();
|
||||
|
||||
// Try to detect actual server URL from output
|
||||
this.detectUrlFromOutput(server, content);
|
||||
await this.detectUrlFromOutput(server, content);
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(server, content);
|
||||
@@ -594,261 +766,305 @@ class DevServerService {
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
// Check if already running
|
||||
if (this.runningServers.has(worktreePath)) {
|
||||
const existing = this.runningServers.get(worktreePath)!;
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: existing.worktreePath,
|
||||
port: existing.port,
|
||||
url: existing.url,
|
||||
message: `Dev server already running on port ${existing.port}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the worktree exists
|
||||
if (!(await this.fileExists(worktreePath))) {
|
||||
// Check if already running or starting
|
||||
if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) {
|
||||
const existing = this.runningServers.get(worktreePath);
|
||||
if (existing) {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: existing.worktreePath,
|
||||
port: existing.port,
|
||||
url: existing.url,
|
||||
message: `Dev server already running on port ${existing.port}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Worktree path does not exist: ${worktreePath}`,
|
||||
error: 'Dev server is already starting',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine the dev command to use
|
||||
let devCommand: { cmd: string; args: string[] };
|
||||
this.startingServers.add(worktreePath);
|
||||
|
||||
// Normalize custom command: trim whitespace and treat empty strings as undefined
|
||||
const normalizedCustomCommand = customCommand?.trim();
|
||||
|
||||
if (normalizedCustomCommand) {
|
||||
// Use the provided custom command
|
||||
devCommand = this.parseCustomCommand(normalizedCustomCommand);
|
||||
if (!devCommand.cmd) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid custom command: command cannot be empty',
|
||||
};
|
||||
}
|
||||
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
|
||||
} else {
|
||||
// Check for package.json when auto-detecting
|
||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
||||
if (!(await this.fileExists(packageJsonPath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No package.json found in: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get dev command from package manager detection
|
||||
const detectedCommand = await this.getDevCommand(worktreePath);
|
||||
if (!detectedCommand) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not determine dev command for: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
devCommand = detectedCommand;
|
||||
}
|
||||
|
||||
// Find available port
|
||||
let port: number;
|
||||
try {
|
||||
port = await this.findAvailablePort();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Port allocation failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Reserve the port (port was already force-killed in findAvailablePort)
|
||||
this.allocatedPorts.add(port);
|
||||
|
||||
// Also kill common related ports (livereload, etc.)
|
||||
// Some dev servers use fixed ports for HMR/livereload regardless of main port
|
||||
for (const relatedPort of LIVERELOAD_PORTS) {
|
||||
this.killProcessOnPort(relatedPort);
|
||||
}
|
||||
|
||||
// Small delay to ensure related ports are freed
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
logger.info(`Starting dev server on port ${port}`);
|
||||
logger.debug(`Working directory (cwd): ${worktreePath}`);
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Spawn the dev process with PORT environment variable
|
||||
// FORCE_COLOR enables colored output even when not running in a TTY
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
FORCE_COLOR: '1',
|
||||
// Some tools use these additional env vars for color detection
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
const devProcess = spawn(devCommand.cmd, devCommand.args, {
|
||||
cwd: worktreePath,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
// Track if process failed early using object to work around TypeScript narrowing
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Create server info early so we can reference it in handlers
|
||||
// We'll add it to runningServers after verifying the process started successfully
|
||||
const hostname = process.env.HOSTNAME || 'localhost';
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
|
||||
port,
|
||||
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||
urlDetectionTimeout: null, // Will be set after server starts successfully
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr with buffer management and event emission
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to clean up resources and emit stop event
|
||||
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
|
||||
if (serverInfo.flushTimeout) {
|
||||
clearTimeout(serverInfo.flushTimeout);
|
||||
serverInfo.flushTimeout = null;
|
||||
// Verify the worktree exists
|
||||
if (!(await this.fileExists(worktreePath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Worktree path does not exist: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear URL detection timeout to prevent stale fallback emission
|
||||
if (serverInfo.urlDetectionTimeout) {
|
||||
clearTimeout(serverInfo.urlDetectionTimeout);
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
// Determine the dev command to use
|
||||
let devCommand: { cmd: string; args: string[] };
|
||||
|
||||
// Normalize custom command: trim whitespace and treat empty strings as undefined
|
||||
const normalizedCustomCommand = customCommand?.trim();
|
||||
|
||||
if (normalizedCustomCommand) {
|
||||
// Use the provided custom command
|
||||
devCommand = this.parseCustomCommand(normalizedCustomCommand);
|
||||
if (!devCommand.cmd) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid custom command: command cannot be empty',
|
||||
};
|
||||
}
|
||||
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
|
||||
} else {
|
||||
// Check for package.json when auto-detecting
|
||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
||||
if (!(await this.fileExists(packageJsonPath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No package.json found in: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get dev command from package manager detection
|
||||
const detectedCommand = await this.getDevCommand(worktreePath);
|
||||
if (!detectedCommand) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not determine dev command for: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
devCommand = detectedCommand;
|
||||
}
|
||||
|
||||
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
||||
if (this.emitter && !serverInfo.stopping) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
// Find available port
|
||||
let port: number;
|
||||
try {
|
||||
port = await this.findAvailablePort();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Port allocation failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Reserve the port (port was already force-killed in findAvailablePort)
|
||||
this.allocatedPorts.add(port);
|
||||
|
||||
// Also kill common related ports (livereload, etc.)
|
||||
// Some dev servers use fixed ports for HMR/livereload regardless of main port
|
||||
for (const relatedPort of LIVERELOAD_PORTS) {
|
||||
this.killProcessOnPort(relatedPort);
|
||||
}
|
||||
|
||||
// Small delay to ensure related ports are freed
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
logger.info(`Starting dev server on port ${port}`);
|
||||
logger.debug(`Working directory (cwd): ${worktreePath}`);
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Emit starting only after preflight checks pass to avoid dangling starting state.
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:starting', {
|
||||
worktreePath,
|
||||
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
this.allocatedPorts.delete(serverInfo.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
};
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
cleanupAndEmitStop(null, error.message);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
cleanupAndEmitStop(code);
|
||||
});
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
if (status.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to start dev server: ${status.error}`,
|
||||
// Spawn the dev process with PORT environment variable
|
||||
// FORCE_COLOR enables colored output even when not running in a TTY
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
FORCE_COLOR: '1',
|
||||
// Some tools use these additional env vars for color detection
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
}
|
||||
|
||||
if (status.exited) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Dev server process exited immediately. Check server logs for details.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server started successfully - add to running servers map
|
||||
this.runningServers.set(worktreePath, serverInfo);
|
||||
|
||||
// Emit started event for WebSocket subscribers
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:started', {
|
||||
worktreePath,
|
||||
port,
|
||||
url: serverInfo.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
const devProcess = spawn(devCommand.cmd, devCommand.args, {
|
||||
cwd: worktreePath,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Set up URL detection timeout fallback.
|
||||
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
|
||||
// the allocated port is actually in use (server probably started successfully)
|
||||
// and emit a url-detected event with the allocated port as fallback.
|
||||
// Also re-scan the scrollback buffer in case the URL was printed before
|
||||
// our patterns could match (e.g., it was split across multiple data chunks).
|
||||
serverInfo.urlDetectionTimeout = setTimeout(() => {
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
// Track if process failed early using object to work around TypeScript narrowing
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Only run fallback if server is still running and URL wasn't detected
|
||||
if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) {
|
||||
return;
|
||||
// Create server info early so we can reference it in handlers
|
||||
// We'll add it to runningServers after verifying the process started successfully
|
||||
const fallbackHost = 'localhost';
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
|
||||
port,
|
||||
url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||
urlDetectionTimeout: null, // Will be set after server starts successfully
|
||||
customCommand: normalizedCustomCommand,
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
|
||||
logger.error('Failed to handle dev server stdout output:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Re-scan the entire scrollback buffer for URL patterns
|
||||
// This catches cases where the URL was split across multiple output chunks
|
||||
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
|
||||
this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer);
|
||||
// Capture stderr with buffer management and event emission
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
|
||||
logger.error('Failed to handle dev server stderr output:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// If still not detected after full rescan, use the allocated port as fallback
|
||||
if (!serverInfo.urlDetected) {
|
||||
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
|
||||
const fallbackUrl = `http://${hostname}:${port}`;
|
||||
serverInfo.url = fallbackUrl;
|
||||
serverInfo.urlDetected = true;
|
||||
// Helper to clean up resources and emit stop event
|
||||
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
|
||||
if (serverInfo.flushTimeout) {
|
||||
clearTimeout(serverInfo.flushTimeout);
|
||||
serverInfo.flushTimeout = null;
|
||||
}
|
||||
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
// Clear URL detection timeout to prevent stale fallback emission
|
||||
if (serverInfo.urlDetectionTimeout) {
|
||||
clearTimeout(serverInfo.urlDetectionTimeout);
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
}
|
||||
|
||||
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
||||
if (this.emitter && !serverInfo.stopping) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
url: fallbackUrl,
|
||||
port,
|
||||
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, URL_DETECTION_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://${hostname}:${port}`,
|
||||
message: `Dev server started on port ${port}`,
|
||||
},
|
||||
};
|
||||
this.allocatedPorts.delete(serverInfo.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
|
||||
// Persist state change
|
||||
this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err));
|
||||
};
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
cleanupAndEmitStop(null, error.message);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
cleanupAndEmitStop(code);
|
||||
});
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
if (status.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to start dev server: ${status.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.exited) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Dev server process exited immediately. Check server logs for details.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server started successfully - add to running servers map
|
||||
this.runningServers.set(worktreePath, serverInfo);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in startDevServer:', err)
|
||||
);
|
||||
|
||||
// Emit started event for WebSocket subscribers
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:started', {
|
||||
worktreePath,
|
||||
port,
|
||||
url: serverInfo.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Set up URL detection timeout fallback.
|
||||
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
|
||||
// the allocated port is actually in use (server probably started successfully)
|
||||
// and emit a url-detected event with the allocated port as fallback.
|
||||
// Also re-scan the scrollback buffer in case the URL was printed before
|
||||
// our patterns could match (e.g., it was split across multiple data chunks).
|
||||
serverInfo.urlDetectionTimeout = setTimeout(async () => {
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
|
||||
// Only run fallback if server is still running and URL wasn't detected
|
||||
if (
|
||||
serverInfo.stopping ||
|
||||
serverInfo.urlDetected ||
|
||||
!this.runningServers.has(worktreePath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-scan the entire scrollback buffer for URL patterns
|
||||
// This catches cases where the URL was split across multiple output chunks
|
||||
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
|
||||
await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) =>
|
||||
logger.error('Failed to re-scan scrollback buffer:', err)
|
||||
);
|
||||
|
||||
// If still not detected after full rescan, use the allocated port as fallback
|
||||
if (!serverInfo.urlDetected) {
|
||||
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
|
||||
const fallbackUrl = `http://${fallbackHost}:${port}`;
|
||||
serverInfo.url = fallbackUrl;
|
||||
serverInfo.urlDetected = true;
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in URL detection fallback:', err)
|
||||
);
|
||||
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
worktreePath: serverInfo.worktreePath,
|
||||
url: fallbackUrl,
|
||||
port,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, URL_DETECTION_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: serverInfo.worktreePath,
|
||||
port: serverInfo.port,
|
||||
url: serverInfo.url,
|
||||
message: `Dev server started on port ${port}`,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
this.startingServers.delete(worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -904,9 +1120,11 @@ class DevServerService {
|
||||
});
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
// Kill the process; persisted/re-attached entries may not have a process handle.
|
||||
if (server.process && !server.process.killed) {
|
||||
server.process.kill('SIGTERM');
|
||||
} else {
|
||||
this.killProcessOnPort(server.port);
|
||||
}
|
||||
|
||||
// Free the originally-reserved port slot (allocatedPort is immutable and always
|
||||
@@ -915,6 +1133,11 @@ class DevServerService {
|
||||
this.allocatedPorts.delete(server.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in stopDevServer:', err)
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
|
||||
@@ -27,7 +27,11 @@ import type {
|
||||
EventHookTrigger,
|
||||
EventHookShellAction,
|
||||
EventHookHttpAction,
|
||||
EventHookNtfyAction,
|
||||
NtfyEndpointConfig,
|
||||
EventHookContext,
|
||||
} from '@automaker/types';
|
||||
import { ntfyService, type NtfyContext } from './ntfy-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('EventHooks');
|
||||
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
|
||||
/** Default timeout for HTTP requests (10 seconds) */
|
||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* Context available for variable substitution in hooks
|
||||
*/
|
||||
interface HookContext {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
timestamp: string;
|
||||
eventType: EventHookTrigger;
|
||||
}
|
||||
// Use the shared EventHookContext type (aliased locally as HookContext for clarity)
|
||||
type HookContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
@@ -451,6 +444,8 @@ export class EventHookService {
|
||||
await this.executeShellHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'http') {
|
||||
await this.executeHttpHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'ntfy') {
|
||||
await this.executeNtfyHook(hook.action, context, hookName);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Hook "${hookName}" failed:`, error);
|
||||
@@ -558,6 +553,86 @@ export class EventHookService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ntfy.sh notification hook
|
||||
*/
|
||||
private async executeNtfyHook(
|
||||
action: EventHookNtfyAction,
|
||||
context: HookContext,
|
||||
hookName: string
|
||||
): Promise<void> {
|
||||
if (!this.settingsService) {
|
||||
logger.warn('Settings service not available for ntfy hook');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the endpoint configuration
|
||||
const settings = await this.settingsService.getGlobalSettings();
|
||||
const endpoints = settings.ntfyEndpoints || [];
|
||||
const endpoint = endpoints.find((e) => e.id === action.endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert HookContext to NtfyContext
|
||||
const ntfyContext: NtfyContext = {
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
errorType: context.errorType,
|
||||
timestamp: context.timestamp,
|
||||
eventType: context.eventType,
|
||||
};
|
||||
|
||||
// Resolve click URL: action-level overrides endpoint default
|
||||
let clickUrl = action.clickUrl || endpoint.defaultClickUrl;
|
||||
|
||||
// Apply deep-link parameters to the resolved click URL
|
||||
if (clickUrl && context.projectPath) {
|
||||
try {
|
||||
const url = new URL(clickUrl);
|
||||
url.pathname = '/board';
|
||||
// Add projectPath so the UI can switch to the correct project
|
||||
url.searchParams.set('projectPath', context.projectPath);
|
||||
// Add featureId as query param for deep linking to board with feature output modal
|
||||
if (context.featureId) {
|
||||
url.searchParams.set('featureId', context.featureId);
|
||||
}
|
||||
clickUrl = url.toString();
|
||||
} catch (error) {
|
||||
// If URL parsing fails, log warning and use as-is
|
||||
logger.warn(
|
||||
`Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);
|
||||
|
||||
const result = await ntfyService.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: action.title,
|
||||
body: action.body,
|
||||
tags: action.tags,
|
||||
emoji: action.emoji,
|
||||
clickUrl,
|
||||
priority: action.priority,
|
||||
},
|
||||
ntfyContext
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`);
|
||||
} else {
|
||||
logger.info(`Ntfy hook "${hookName}" completed successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
|
||||
@@ -108,16 +108,14 @@ export class ExecutionService {
|
||||
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
buildFeaturePrompt(
|
||||
feature: Feature,
|
||||
taskExecutionPrompts: {
|
||||
implementationInstructions: string;
|
||||
playwrightVerificationInstructions: string;
|
||||
}
|
||||
): string {
|
||||
/**
|
||||
* Build feature description section (without implementation instructions).
|
||||
* Used when planning mode is active — the planning prompt provides its own instructions.
|
||||
*/
|
||||
buildFeatureDescription(feature: Feature): string {
|
||||
const title = this.extractTitleFromDescription(feature.description);
|
||||
|
||||
let prompt = `## Feature Implementation Task
|
||||
let prompt = `## Feature Task
|
||||
|
||||
**Feature ID:** ${feature.id}
|
||||
**Title:** ${title}
|
||||
@@ -146,6 +144,18 @@ ${feature.spec}
|
||||
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
buildFeaturePrompt(
|
||||
feature: Feature,
|
||||
taskExecutionPrompts: {
|
||||
implementationInstructions: string;
|
||||
playwrightVerificationInstructions: string;
|
||||
}
|
||||
): string {
|
||||
let prompt = this.buildFeatureDescription(feature);
|
||||
|
||||
prompt += feature.skipTests
|
||||
? `\n${taskExecutionPrompts.implementationInstructions}`
|
||||
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
|
||||
@@ -169,6 +179,7 @@ ${feature.spec}
|
||||
const abortController = tempRunningFeature.abortController;
|
||||
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
|
||||
let feature: Feature | null = null;
|
||||
let pipelineCompleted = false;
|
||||
|
||||
try {
|
||||
validateWorkingDirectory(projectPath);
|
||||
@@ -214,7 +225,12 @@ ${feature.spec}
|
||||
const branchName = feature.branchName;
|
||||
if (!worktreePath && useWorktrees && branchName) {
|
||||
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
|
||||
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
if (!worktreePath) {
|
||||
throw new Error(
|
||||
`Worktree enabled but no worktree found for feature branch "${branchName}".`
|
||||
);
|
||||
}
|
||||
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
}
|
||||
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||
validateWorkingDirectory(workDir);
|
||||
@@ -268,9 +284,15 @@ ${feature.spec}
|
||||
if (options?.continuationPrompt) {
|
||||
prompt = options.continuationPrompt;
|
||||
} else {
|
||||
prompt =
|
||||
(await this.getPlanningPromptPrefixFn(feature)) +
|
||||
this.buildFeaturePrompt(feature, prompts.taskExecution);
|
||||
const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
|
||||
if (planningPrefix) {
|
||||
// Planning mode active: use planning instructions + feature description only.
|
||||
// Do NOT include implementationInstructions — they conflict with the planning
|
||||
// prompt's "DO NOT proceed with implementation until approval" directive.
|
||||
prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature);
|
||||
} else {
|
||||
prompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
|
||||
}
|
||||
if (feature.planningMode && feature.planningMode !== 'skip') {
|
||||
this.eventBus.emitAutoModeEvent('planning_started', {
|
||||
featureId: feature.id,
|
||||
@@ -304,6 +326,7 @@ ${feature.spec}
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
reasoningEffort: feature.reasoningEffort,
|
||||
providerId: feature.providerId,
|
||||
branchName: feature.branchName ?? null,
|
||||
}
|
||||
);
|
||||
@@ -370,6 +393,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
reasoningEffort: feature.reasoningEffort,
|
||||
providerId: feature.providerId,
|
||||
branchName: feature.branchName ?? null,
|
||||
}
|
||||
);
|
||||
@@ -408,6 +432,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
});
|
||||
pipelineCompleted = true;
|
||||
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
|
||||
const refreshed = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (refreshed?.status === 'merge_conflict') {
|
||||
@@ -461,7 +486,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;
|
||||
|
||||
try {
|
||||
if (agentOutput) {
|
||||
// Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps)
|
||||
// This prevents overwriting accumulated summaries with just the last step's output
|
||||
// The agent-executor already extracts and saves summaries during execution
|
||||
if (agentOutput && !completedFeature?.summary) {
|
||||
const summary = extractSummary(agentOutput);
|
||||
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
|
||||
}
|
||||
@@ -515,7 +543,30 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
}
|
||||
} else {
|
||||
logger.error(`Feature ${featureId} failed:`, error);
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||
// If pipeline steps completed successfully, don't send the feature back to backlog.
|
||||
// The pipeline work is done — set to waiting_approval so the user can review.
|
||||
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
|
||||
if (pipelineCompleted) {
|
||||
logger.info(
|
||||
`[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
|
||||
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
|
||||
);
|
||||
}
|
||||
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
|
||||
let currentStatus: string | undefined;
|
||||
try {
|
||||
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
|
||||
currentStatus = currentFeature?.status;
|
||||
} catch (loadErr) {
|
||||
// If loading fails, log it and proceed with the status update anyway
|
||||
logger.warn(
|
||||
`[executeFeature] Failed to reload feature ${featureId} for status check:`,
|
||||
loadErr
|
||||
);
|
||||
}
|
||||
if (currentStatus !== 'merge_conflict') {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
featureId,
|
||||
featureName: feature?.title,
|
||||
|
||||
@@ -34,6 +34,7 @@ export type RunAgentFn = (
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
providerId?: string;
|
||||
branchName?: string | null;
|
||||
}
|
||||
) => Promise<void>;
|
||||
|
||||
@@ -378,6 +378,7 @@ export class FeatureLoader {
|
||||
description: featureData.description || '',
|
||||
...featureData,
|
||||
id: featureId,
|
||||
createdAt: featureData.createdAt || new Date().toISOString(),
|
||||
imagePaths: migratedImagePaths,
|
||||
descriptionHistory: initialHistory,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types';
|
||||
import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
import {
|
||||
atomicWriteJson,
|
||||
readJsonWithRecovery,
|
||||
@@ -28,9 +29,40 @@ import type { EventEmitter } from '../lib/events.js';
|
||||
import type { AutoModeEventType } from './typed-event-bus.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
import { pipelineService } from './pipeline-service.js';
|
||||
|
||||
const logger = createLogger('FeatureStateManager');
|
||||
|
||||
// Notification type constants
|
||||
const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval';
|
||||
const NOTIFICATION_TYPE_VERIFIED = 'feature_verified';
|
||||
const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error';
|
||||
const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error';
|
||||
|
||||
// Notification title constants
|
||||
const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review';
|
||||
const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified';
|
||||
const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed';
|
||||
const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error';
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
* This is the payload that comes with 'auto-mode:event' events
|
||||
*/
|
||||
interface AutoModeEventPayload {
|
||||
type?: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
passes?: boolean;
|
||||
executionMode?: 'auto' | 'manual';
|
||||
message?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
projectPath?: string;
|
||||
/** Status field present when type === 'feature_status_changed' */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureStateManager handles feature status updates with persistence guarantees.
|
||||
*
|
||||
@@ -43,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
|
||||
export class FeatureStateManager {
|
||||
private events: EventEmitter;
|
||||
private featureLoader: FeatureLoader;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
|
||||
this.events = events;
|
||||
this.featureLoader = featureLoader;
|
||||
|
||||
// Subscribe to error events to create notifications
|
||||
this.unsubscribe = events.subscribe((type, payload) => {
|
||||
if (type === 'auto-mode:event') {
|
||||
this.handleAutoModeEventError(payload as AutoModeEventPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup subscriptions
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,77 +154,18 @@ export class FeatureStateManager {
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||
// Badge will show for 2 minutes after this timestamp
|
||||
if (status === 'waiting_approval') {
|
||||
// Handle justFinishedAt timestamp based on status
|
||||
const shouldSetJustFinishedAt = status === 'waiting_approval';
|
||||
const shouldClearJustFinishedAt = status !== 'waiting_approval';
|
||||
if (shouldSetJustFinishedAt) {
|
||||
feature.justFinishedAt = new Date().toISOString();
|
||||
} else if (shouldClearJustFinishedAt) {
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
|
||||
// Finalize task statuses when feature is done:
|
||||
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
|
||||
// - Do NOT mark pending tasks as completed (they were never started)
|
||||
// - Clear currentTaskId since no task is actively running
|
||||
// This prevents cards in "waiting for review" from appearing to still have running tasks
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
} else if (status === 'verified') {
|
||||
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
|
||||
// Do NOT mark pending tasks as completed - they were never started
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
} else {
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
// Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
|
||||
if (status === 'waiting_approval' || status === 'verified') {
|
||||
this.finalizeInProgressTasks(feature, featureId, status);
|
||||
}
|
||||
|
||||
// PERSIST BEFORE EMIT (Pitfall 2)
|
||||
@@ -191,19 +182,21 @@ export class FeatureStateManager {
|
||||
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
const displayName = this.getFeatureDisplayName(feature, featureId);
|
||||
|
||||
if (status === 'waiting_approval') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'Feature Ready for Review',
|
||||
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
||||
type: NOTIFICATION_TYPE_WAITING_APPROVAL,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_WAITING_APPROVAL,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
} else if (status === 'verified') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_verified',
|
||||
title: 'Feature Verified',
|
||||
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
||||
type: NOTIFICATION_TYPE_VERIFIED,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_VERIFIED,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
@@ -252,7 +245,7 @@ export class FeatureStateManager {
|
||||
const currentStatus = feature?.status;
|
||||
|
||||
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
|
||||
if (currentStatus && currentStatus.startsWith('pipeline_')) {
|
||||
if (isPipelineStatus(currentStatus)) {
|
||||
logger.info(
|
||||
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
|
||||
);
|
||||
@@ -270,7 +263,8 @@ export class FeatureStateManager {
|
||||
|
||||
/**
|
||||
* Shared helper that scans features in a project directory and resets any stuck
|
||||
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
|
||||
* in transient states (in_progress, interrupted) back to resting states.
|
||||
* Pipeline_* statuses are preserved so they can be resumed.
|
||||
*
|
||||
* Also resets:
|
||||
* - generating planSpec status back to pending
|
||||
@@ -324,10 +318,7 @@ export class FeatureStateManager {
|
||||
|
||||
// Reset features in active execution states back to a resting state
|
||||
// After a server restart, no processes are actually running
|
||||
const isActiveState =
|
||||
originalStatus === 'in_progress' ||
|
||||
originalStatus === 'interrupted' ||
|
||||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
|
||||
const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted';
|
||||
|
||||
if (isActiveState) {
|
||||
const hasApprovedPlan = feature.planSpec?.status === 'approved';
|
||||
@@ -338,6 +329,17 @@ export class FeatureStateManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle pipeline_* statuses separately: preserve them so they can be resumed
|
||||
// but still count them as needing attention if they were stuck.
|
||||
if (isPipelineStatus(originalStatus)) {
|
||||
// We don't change the status, but we still want to reset planSpec/task states
|
||||
// if they were stuck in transient generation/execution modes.
|
||||
// No feature.status change here.
|
||||
logger.debug(
|
||||
`[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
// Reset generating planSpec status back to pending (spec generation was interrupted)
|
||||
if (feature.planSpec?.status === 'generating') {
|
||||
feature.planSpec.status = 'pending';
|
||||
@@ -396,10 +398,12 @@ export class FeatureStateManager {
|
||||
* Resets:
|
||||
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
||||
* - interrupted features back to ready (if has plan) or backlog (if no plan)
|
||||
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
|
||||
* - generating planSpec status back to pending
|
||||
* - in_progress tasks back to pending
|
||||
*
|
||||
* Preserves:
|
||||
* - pipeline_* statuses (so resumePipelineFeature can resume from correct step)
|
||||
*
|
||||
* @param projectPath - The project path to reset features for
|
||||
*/
|
||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||
@@ -530,6 +534,10 @@ export class FeatureStateManager {
|
||||
* This is called after agent execution completes to save a summary
|
||||
* extracted from the agent's output using <summary> tags.
|
||||
*
|
||||
* For pipeline features (status starts with pipeline_), summaries are accumulated
|
||||
* across steps with a header identifying each step. For non-pipeline features,
|
||||
* the summary is replaced entirely.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param featureId - The feature ID
|
||||
* @param summary - The summary text to save
|
||||
@@ -537,6 +545,7 @@ export class FeatureStateManager {
|
||||
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
const normalizedSummary = summary.trim();
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
@@ -552,7 +561,63 @@ export class FeatureStateManager {
|
||||
return;
|
||||
}
|
||||
|
||||
feature.summary = summary;
|
||||
if (!normalizedSummary) {
|
||||
logger.debug(
|
||||
`[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For pipeline features, accumulate summaries across steps
|
||||
if (isPipelineStatus(feature.status)) {
|
||||
// If we already have a non-phase summary (typically the initial implementation
|
||||
// summary from in_progress), normalize it into a named phase before appending
|
||||
// pipeline step summaries. This keeps the format consistent for UI phase parsing.
|
||||
const implementationHeader = '### Implementation';
|
||||
if (feature.summary && !feature.summary.trimStart().startsWith('### ')) {
|
||||
feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`;
|
||||
}
|
||||
|
||||
const stepName = await this.getPipelineStepName(projectPath, feature.status);
|
||||
const stepHeader = `### ${stepName}`;
|
||||
const stepSection = `${stepHeader}\n\n${normalizedSummary}`;
|
||||
|
||||
if (feature.summary) {
|
||||
// Check if this step already exists in the summary (e.g., if retried)
|
||||
// Use section splitting to only match real section boundaries, not text in body content
|
||||
const separator = '\n\n---\n\n';
|
||||
const sections = feature.summary.split(separator);
|
||||
let replaced = false;
|
||||
const updatedSections = sections.map((section) => {
|
||||
if (section.startsWith(`${stepHeader}\n\n`)) {
|
||||
replaced = true;
|
||||
return stepSection;
|
||||
}
|
||||
return section;
|
||||
});
|
||||
|
||||
if (replaced) {
|
||||
feature.summary = updatedSections.join(separator);
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
} else {
|
||||
// Append as a new section
|
||||
feature.summary = `${feature.summary}${separator}${stepSection}`;
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
feature.summary = stepSection;
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
feature.summary = normalizedSummary;
|
||||
}
|
||||
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
@@ -562,13 +627,42 @@ export class FeatureStateManager {
|
||||
this.emitAutoModeEvent('auto_mode_summary', {
|
||||
featureId,
|
||||
projectPath,
|
||||
summary,
|
||||
summary: feature.summary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save summary for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the pipeline step name from the current pipeline status.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param status - The current pipeline status (e.g., 'pipeline_abc123')
|
||||
* @returns The step name, or a fallback based on the step ID
|
||||
*/
|
||||
private async getPipelineStepName(projectPath: string, status: string): Promise<string> {
|
||||
try {
|
||||
const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline);
|
||||
if (stepId) {
|
||||
const step = await pipelineService.getStep(projectPath, stepId);
|
||||
if (step) return step.name;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
// Fallback: derive a human-readable name from the status suffix
|
||||
// e.g., 'pipeline_code_review' → 'Code Review'
|
||||
const suffix = status.replace('pipeline_', '');
|
||||
return suffix
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of a specific task within planSpec.tasks
|
||||
*
|
||||
@@ -581,7 +675,8 @@ export class FeatureStateManager {
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
taskId: string,
|
||||
status: ParsedTask['status']
|
||||
status: ParsedTask['status'],
|
||||
summary?: string
|
||||
): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
@@ -604,6 +699,9 @@ export class FeatureStateManager {
|
||||
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = status;
|
||||
if (summary) {
|
||||
task.summary = summary;
|
||||
}
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
@@ -615,6 +713,7 @@ export class FeatureStateManager {
|
||||
projectPath,
|
||||
taskId,
|
||||
status,
|
||||
summary,
|
||||
tasks: feature.planSpec.tasks,
|
||||
});
|
||||
} else {
|
||||
@@ -628,6 +727,137 @@ export class FeatureStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a feature, preferring title over feature ID.
|
||||
* Empty string titles are treated as missing and fallback to featureId.
|
||||
*
|
||||
* @param feature - The feature to get the display name for
|
||||
* @param featureId - The feature ID to use as fallback
|
||||
* @returns The display name (title or feature ID)
|
||||
*/
|
||||
private getFeatureDisplayName(feature: Feature, featureId: string): string {
|
||||
// Use title if it's a non-empty string, otherwise fallback to featureId
|
||||
return feature.title && feature.title.trim() ? feature.title : featureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-mode events to create error notifications.
|
||||
* This listens for error events and creates notifications to alert users.
|
||||
*/
|
||||
private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> {
|
||||
if (!payload.type) return;
|
||||
|
||||
// Only handle error events
|
||||
if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
// For auto_mode_feature_complete, only notify on failures (passes === false)
|
||||
if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get project path - handle different event formats
|
||||
const projectPath = payload.projectPath;
|
||||
if (!projectPath) return;
|
||||
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Determine notification type and title based on event type
|
||||
// Only auto_mode_feature_complete events should create feature_error notifications
|
||||
const isFeatureError = payload.type === 'auto_mode_feature_complete';
|
||||
const notificationType = isFeatureError
|
||||
? NOTIFICATION_TYPE_FEATURE_ERROR
|
||||
: NOTIFICATION_TYPE_AUTO_MODE_ERROR;
|
||||
const notificationTitle = isFeatureError
|
||||
? NOTIFICATION_TITLE_FEATURE_ERROR
|
||||
: NOTIFICATION_TITLE_AUTO_MODE_ERROR;
|
||||
|
||||
// Build error message
|
||||
let errorMessage = payload.message || 'An error occurred';
|
||||
if (payload.error) {
|
||||
errorMessage = payload.error;
|
||||
}
|
||||
|
||||
// Use feature title as notification title when available, fall back to gesture name
|
||||
let title = notificationTitle;
|
||||
if (payload.featureId) {
|
||||
const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId);
|
||||
if (displayName) {
|
||||
title = displayName;
|
||||
errorMessage = `${notificationTitle}: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
await notificationService.createNotification({
|
||||
type: notificationType,
|
||||
title,
|
||||
message: errorMessage,
|
||||
featureId: payload.featureId,
|
||||
projectPath,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
logger.warn(`Failed to create error notification:`, notificationError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature display name by loading the feature directly.
|
||||
*/
|
||||
private async getFeatureDisplayNameById(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) return null;
|
||||
return this.getFeatureDisplayName(feature, featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize in-progress tasks when a feature reaches a terminal state.
|
||||
* Marks in_progress tasks as completed but leaves pending tasks untouched.
|
||||
*
|
||||
* @param feature - The feature whose tasks should be finalized
|
||||
* @param featureId - The feature ID for logging
|
||||
* @param targetStatus - The status the feature is transitioning to
|
||||
*/
|
||||
private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void {
|
||||
if (!feature.planSpec?.tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an auto-mode event via the event emitter
|
||||
*
|
||||
|
||||
282
apps/server/src/services/ntfy-service.ts
Normal file
282
apps/server/src/services/ntfy-service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Ntfy Service - Sends push notifications via ntfy.sh
|
||||
*
|
||||
* Provides integration with ntfy.sh for push notifications.
|
||||
* Supports custom servers, authentication, tags, emojis, and click actions.
|
||||
*
|
||||
* @see https://docs.ntfy.sh/publish/
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('Ntfy');
|
||||
|
||||
/** Default timeout for ntfy HTTP requests (10 seconds) */
|
||||
const DEFAULT_NTFY_TIMEOUT = 10000;
|
||||
|
||||
// Re-export EventHookContext as NtfyContext for backward compatibility
|
||||
export type NtfyContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Ntfy Service
|
||||
*
|
||||
* Handles sending notifications to ntfy.sh endpoints.
|
||||
*/
|
||||
export class NtfyService {
|
||||
/**
|
||||
* Send a notification to a ntfy.sh endpoint
|
||||
*
|
||||
* @param endpoint The ntfy.sh endpoint configuration
|
||||
* @param options Notification options (title, body, tags, etc.)
|
||||
* @param context Context for variable substitution
|
||||
*/
|
||||
async sendNotification(
|
||||
endpoint: NtfyEndpointConfig,
|
||||
options: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tags?: string;
|
||||
emoji?: string;
|
||||
clickUrl?: string;
|
||||
priority?: 1 | 2 | 3 | 4 | 5;
|
||||
},
|
||||
context: NtfyContext
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!endpoint.enabled) {
|
||||
logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`);
|
||||
return { success: false, error: 'Endpoint is disabled' };
|
||||
}
|
||||
|
||||
// Validate endpoint configuration
|
||||
const validationError = this.validateEndpoint(endpoint);
|
||||
if (validationError) {
|
||||
logger.error(`Invalid ntfy endpoint configuration: ${validationError}`);
|
||||
return { success: false, error: validationError };
|
||||
}
|
||||
|
||||
// Build URL
|
||||
const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`;
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
// Title (with variable substitution)
|
||||
const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context);
|
||||
if (title) {
|
||||
headers['Title'] = title;
|
||||
}
|
||||
|
||||
// Priority
|
||||
const priority = options.priority || 3;
|
||||
headers['Priority'] = String(priority);
|
||||
|
||||
// Tags and emoji
|
||||
const tags = this.buildTags(
|
||||
options.tags || endpoint.defaultTags,
|
||||
options.emoji || endpoint.defaultEmoji
|
||||
);
|
||||
if (tags) {
|
||||
headers['Tags'] = tags;
|
||||
}
|
||||
|
||||
// Click action URL
|
||||
const clickUrl = this.substituteVariables(
|
||||
options.clickUrl || endpoint.defaultClickUrl || '',
|
||||
context
|
||||
);
|
||||
if (clickUrl) {
|
||||
headers['Click'] = clickUrl;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
this.addAuthHeaders(headers, endpoint);
|
||||
|
||||
// Message body (with variable substitution)
|
||||
const body = this.substituteVariables(options.body || this.getDefaultBody(context), context);
|
||||
|
||||
logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Ntfy notification sent successfully to ${endpoint.name}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`);
|
||||
return { success: false, error: 'Request timed out' };
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Ntfy notification failed: ${errorMessage}`);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an ntfy endpoint configuration
|
||||
*/
|
||||
validateEndpoint(endpoint: NtfyEndpointConfig): string | null {
|
||||
// Validate server URL
|
||||
if (!endpoint.serverUrl) {
|
||||
return 'Server URL is required';
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(endpoint.serverUrl);
|
||||
} catch {
|
||||
return 'Invalid server URL format';
|
||||
}
|
||||
|
||||
// Validate topic
|
||||
if (!endpoint.topic) {
|
||||
return 'Topic is required';
|
||||
}
|
||||
|
||||
if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) {
|
||||
return 'Topic cannot contain spaces';
|
||||
}
|
||||
|
||||
// Validate authentication
|
||||
if (endpoint.authType === 'basic') {
|
||||
if (!endpoint.username || !endpoint.password) {
|
||||
return 'Username and password are required for basic authentication';
|
||||
}
|
||||
} else if (endpoint.authType === 'token') {
|
||||
if (!endpoint.token) {
|
||||
return 'Access token is required for token authentication';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tags string from tags and emoji
|
||||
*/
|
||||
private buildTags(tags?: string, emoji?: string): string {
|
||||
const tagList: string[] = [];
|
||||
|
||||
if (tags) {
|
||||
// Split by comma and trim whitespace
|
||||
const parsedTags = tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
tagList.push(...parsedTags);
|
||||
}
|
||||
|
||||
if (emoji) {
|
||||
// Add emoji as first tag if it looks like a shortcode
|
||||
if (emoji.startsWith(':') && emoji.endsWith(':')) {
|
||||
tagList.unshift(emoji.slice(1, -1));
|
||||
} else if (!emoji.includes(' ')) {
|
||||
// If it's a single emoji or shortcode without colons, add as-is
|
||||
tagList.unshift(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
return tagList.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add authentication headers based on auth type
|
||||
*/
|
||||
private addAuthHeaders(headers: Record<string, string>, endpoint: NtfyEndpointConfig): void {
|
||||
if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) {
|
||||
const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString(
|
||||
'base64'
|
||||
);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
} else if (endpoint.authType === 'token' && endpoint.token) {
|
||||
headers['Authorization'] = `Bearer ${endpoint.token}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default title based on event context
|
||||
*/
|
||||
private getDefaultTitle(context: NtfyContext): string {
|
||||
const eventName = this.formatEventName(context.eventType);
|
||||
if (context.featureName) {
|
||||
return `${eventName}: ${context.featureName}`;
|
||||
}
|
||||
return eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default body based on event context
|
||||
*/
|
||||
private getDefaultBody(context: NtfyContext): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.featureName) {
|
||||
lines.push(`Feature: ${context.featureName}`);
|
||||
}
|
||||
if (context.featureId) {
|
||||
lines.push(`ID: ${context.featureId}`);
|
||||
}
|
||||
if (context.projectName) {
|
||||
lines.push(`Project: ${context.projectName}`);
|
||||
}
|
||||
if (context.error) {
|
||||
lines.push(`Error: ${context.error}`);
|
||||
}
|
||||
lines.push(`Time: ${context.timestamp}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event type to human-readable name
|
||||
*/
|
||||
private formatEventName(eventType: string): string {
|
||||
const eventNames: Record<string, string> = {
|
||||
feature_created: 'Feature Created',
|
||||
feature_success: 'Feature Completed',
|
||||
feature_error: 'Feature Failed',
|
||||
auto_mode_complete: 'Auto Mode Complete',
|
||||
auto_mode_error: 'Auto Mode Error',
|
||||
};
|
||||
return eventNames[eventType] || eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
private substituteVariables(template: string, context: NtfyContext): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||
const value = context[variable as keyof NtfyContext];
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const ntfyService = new NtfyService();
|
||||
@@ -115,6 +115,7 @@ export class PipelineOrchestrator {
|
||||
projectPath,
|
||||
});
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
const currentStatus = `pipeline_${step.id}`;
|
||||
await this.runAgentFn(
|
||||
workDir,
|
||||
featureId,
|
||||
@@ -133,6 +134,8 @@ export class PipelineOrchestrator {
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
reasoningEffort: feature.reasoningEffort,
|
||||
status: currentStatus,
|
||||
providerId: feature.providerId,
|
||||
}
|
||||
);
|
||||
try {
|
||||
@@ -165,7 +168,18 @@ export class PipelineOrchestrator {
|
||||
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
|
||||
return (
|
||||
prompt +
|
||||
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.`
|
||||
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.\n\n` +
|
||||
`**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**\n\n` +
|
||||
`<summary>\n` +
|
||||
`## Summary: ${step.name}\n\n` +
|
||||
`### Changes Implemented\n` +
|
||||
`- [List all changes made in this step]\n\n` +
|
||||
`### Files Modified\n` +
|
||||
`- [List all files modified in this step]\n\n` +
|
||||
`### Outcome\n` +
|
||||
`- [Describe the result of this step]\n` +
|
||||
`</summary>\n\n` +
|
||||
`The <summary> and </summary> tags MUST be on their own lines. This is REQUIRED.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -336,6 +350,7 @@ export class PipelineOrchestrator {
|
||||
});
|
||||
const abortController = runningEntry.abortController;
|
||||
runningEntry.branchName = feature.branchName ?? null;
|
||||
let pipelineCompleted = false;
|
||||
|
||||
try {
|
||||
validateWorkingDirectory(projectPath);
|
||||
@@ -389,6 +404,7 @@ export class PipelineOrchestrator {
|
||||
};
|
||||
|
||||
await this.executePipeline(context);
|
||||
pipelineCompleted = true;
|
||||
|
||||
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
|
||||
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
|
||||
@@ -425,8 +441,21 @@ export class PipelineOrchestrator {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If pipeline steps completed successfully, don't send the feature back to backlog.
|
||||
// The pipeline work is done — set to waiting_approval so the user can review.
|
||||
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
|
||||
if (pipelineCompleted) {
|
||||
logger.info(
|
||||
`[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` +
|
||||
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
|
||||
);
|
||||
}
|
||||
logger.error(`Pipeline resume failed for ${featureId}:`, error);
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
|
||||
const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
|
||||
if (currentFeature?.status !== 'merge_conflict') {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
@@ -490,7 +519,10 @@ export class PipelineOrchestrator {
|
||||
requirePlanApproval: false,
|
||||
useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt,
|
||||
autoLoadClaudeMd: context.autoLoadClaudeMd,
|
||||
thinkingLevel: context.feature.thinkingLevel,
|
||||
reasoningEffort: context.feature.reasoningEffort,
|
||||
status: context.feature.status,
|
||||
providerId: context.feature.providerId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const logger = createLogger('PullService');
|
||||
export interface PullOptions {
|
||||
/** Remote name to pull from (defaults to 'origin') */
|
||||
remote?: string;
|
||||
/** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */
|
||||
remoteBranch?: string;
|
||||
/** When true, automatically stash local changes before pulling and reapply after */
|
||||
stashIfNeeded?: boolean;
|
||||
}
|
||||
@@ -243,6 +245,7 @@ export async function performPull(
|
||||
): Promise<PullResult> {
|
||||
const targetRemote = options?.remote || 'origin';
|
||||
const stashIfNeeded = options?.stashIfNeeded ?? false;
|
||||
const targetRemoteBranch = options?.remoteBranch;
|
||||
|
||||
// 1. Get current branch name
|
||||
let branchName: string;
|
||||
@@ -313,24 +316,34 @@ export async function performPull(
|
||||
}
|
||||
|
||||
// 7. Verify upstream tracking or remote branch exists
|
||||
const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
|
||||
if (upstreamStatus === 'none') {
|
||||
let stashRecoveryFailed = false;
|
||||
if (didStash) {
|
||||
const stashPopped = await tryPopStash(worktreePath);
|
||||
stashRecoveryFailed = !stashPopped;
|
||||
// Skip this check when a specific remote branch is provided - we always use
|
||||
// explicit 'git pull <remote> <branch>' args in that case.
|
||||
let upstreamStatus: UpstreamStatus = 'tracking';
|
||||
if (!targetRemoteBranch) {
|
||||
upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
|
||||
if (upstreamStatus === 'none') {
|
||||
let stashRecoveryFailed = false;
|
||||
if (didStash) {
|
||||
const stashPopped = await tryPopStash(worktreePath);
|
||||
stashRecoveryFailed = !stashPopped;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
|
||||
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
|
||||
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 8. Pull latest changes
|
||||
// When a specific remote branch is requested, always use explicit remote + branch args.
|
||||
// When the branch has a configured upstream tracking ref, let Git use it automatically.
|
||||
// When only the remote branch exists (no tracking ref), explicitly specify remote and branch.
|
||||
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
|
||||
const pullArgs = targetRemoteBranch
|
||||
? ['pull', targetRemote, targetRemoteBranch]
|
||||
: upstreamStatus === 'tracking'
|
||||
? ['pull']
|
||||
: ['pull', targetRemote, branchName];
|
||||
let pullConflict = false;
|
||||
let pullConflictFiles: string[] = [];
|
||||
|
||||
|
||||
@@ -618,6 +618,36 @@ export class SettingsService {
|
||||
ignoreEmptyArrayOverwrite('eventHooks');
|
||||
}
|
||||
|
||||
// Guard ntfyEndpoints against accidental wipe
|
||||
// (similar to eventHooks, these are user-configured and shouldn't be lost)
|
||||
// Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing)
|
||||
const allowEmptyNtfyEndpoints =
|
||||
(sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true;
|
||||
// Remove the flag so it doesn't get persisted
|
||||
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints;
|
||||
|
||||
if (!allowEmptyNtfyEndpoints) {
|
||||
const currentNtfyLen = Array.isArray(current.ntfyEndpoints)
|
||||
? current.ntfyEndpoints.length
|
||||
: 0;
|
||||
const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints)
|
||||
? sanitizedUpdates.ntfyEndpoints.length
|
||||
: currentNtfyLen;
|
||||
|
||||
if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) {
|
||||
logger.warn(
|
||||
'[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.',
|
||||
{
|
||||
currentNtfyLen,
|
||||
newNtfyLen,
|
||||
}
|
||||
);
|
||||
delete sanitizedUpdates.ntfyEndpoints;
|
||||
}
|
||||
} else {
|
||||
logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch');
|
||||
}
|
||||
|
||||
// Empty object overwrite guard
|
||||
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
@@ -1023,6 +1053,8 @@ export class SettingsService {
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
|
||||
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
|
||||
@@ -101,12 +101,32 @@ export function detectTaskStartMarker(text: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect [TASK_COMPLETE] marker in text and extract task ID
|
||||
* Detect [TASK_COMPLETE] marker in text and extract task ID and summary
|
||||
* Format: [TASK_COMPLETE] T###: Brief summary
|
||||
*/
|
||||
export function detectTaskCompleteMarker(text: string): string | null {
|
||||
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/);
|
||||
return match ? match[1] : null;
|
||||
export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null {
|
||||
// Use a regex that captures the summary until newline or next task marker
|
||||
// Allow brackets in summary content (e.g., "supports array[index] access")
|
||||
// Pattern breakdown:
|
||||
// - \[TASK_COMPLETE\]\s* - Match the marker
|
||||
// - (T\d{3}) - Capture task ID
|
||||
// - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket)
|
||||
// - But we want to allow brackets in summary, so we use a different approach:
|
||||
// - Match summary until newline, then trim any trailing markers in post-processing
|
||||
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i);
|
||||
if (!match) return null;
|
||||
|
||||
// Post-process: remove trailing task markers from summary if present
|
||||
let summary = match[2]?.trim();
|
||||
if (summary) {
|
||||
// Remove trailing content that looks like another marker
|
||||
summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
id: match[1],
|
||||
summary: summary || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,10 +214,14 @@ export function extractSummary(text: string): string | null {
|
||||
}
|
||||
|
||||
// Check for ## Summary section (use last match)
|
||||
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi);
|
||||
// Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections
|
||||
// (like "### Root Cause", "### Fix Applied") that belong to the summary content.
|
||||
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi);
|
||||
const sectionMatch = getLastMatch(sectionMatches);
|
||||
if (sectionMatch) {
|
||||
return truncate(sectionMatch[1].trim(), 500);
|
||||
const content = sectionMatch[1].trim();
|
||||
// Keep full content (including ### subsections) up to max length
|
||||
return content.length > 500 ? `${content.substring(0, 500)}...` : content;
|
||||
}
|
||||
|
||||
// Check for **Goal**: section (lite mode, use last match)
|
||||
|
||||
@@ -39,6 +39,18 @@ export interface WorktreeInfo {
|
||||
* 3. Listing all worktrees with normalized paths
|
||||
*/
|
||||
export class WorktreeResolver {
|
||||
private normalizeBranchName(branchName: string | null | undefined): string | null {
|
||||
if (!branchName) return null;
|
||||
let normalized = branchName.trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
normalized = normalized.replace(/^refs\/heads\//, '');
|
||||
normalized = normalized.replace(/^refs\/remotes\/[^/]+\//, '');
|
||||
normalized = normalized.replace(/^(origin|upstream)\//, '');
|
||||
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current branch name for a git repository
|
||||
*
|
||||
@@ -64,6 +76,9 @@ export class WorktreeResolver {
|
||||
*/
|
||||
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const normalizedTargetBranch = this.normalizeBranchName(branchName);
|
||||
if (!normalizedTargetBranch) return null;
|
||||
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
@@ -76,10 +91,10 @@ export class WorktreeResolver {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
currentBranch = this.normalizeBranchName(line.slice(7));
|
||||
} else if (line === '' && currentPath && currentBranch) {
|
||||
// End of a worktree entry
|
||||
if (currentBranch === branchName) {
|
||||
if (currentBranch === normalizedTargetBranch) {
|
||||
// Resolve to absolute path - git may return relative paths
|
||||
// On Windows, this is critical for cwd to work correctly
|
||||
// On all platforms, absolute paths ensure consistent behavior
|
||||
@@ -91,7 +106,7 @@ export class WorktreeResolver {
|
||||
}
|
||||
|
||||
// Check the last entry (if file doesn't end with newline)
|
||||
if (currentPath && currentBranch && currentBranch === branchName) {
|
||||
if (currentPath && currentBranch && currentBranch === normalizedTargetBranch) {
|
||||
return this.resolvePath(projectPath, currentPath);
|
||||
}
|
||||
|
||||
@@ -123,7 +138,7 @@ export class WorktreeResolver {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
currentBranch = this.normalizeBranchName(line.slice(7));
|
||||
} else if (line.startsWith('detached')) {
|
||||
// Detached HEAD - branch is null
|
||||
currentBranch = null;
|
||||
|
||||
333
apps/server/tests/unit/lib/file-editor-store-logic.test.ts
Normal file
333
apps/server/tests/unit/lib/file-editor-store-logic.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeIsDirty,
|
||||
updateTabWithContent as updateTabContent,
|
||||
markTabAsSaved as markTabSaved,
|
||||
} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts';
|
||||
|
||||
/**
|
||||
* Unit tests for the file editor store logic, focusing on the unsaved indicator fix.
|
||||
*
|
||||
* The bug was: File unsaved indicators weren't working reliably - editing a file
|
||||
* and saving it would sometimes leave the dirty indicator (dot) visible.
|
||||
*
|
||||
* Root causes:
|
||||
* 1. Stale closure in handleSave - captured activeTab could have old content
|
||||
* 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store
|
||||
*
|
||||
* Fix:
|
||||
* - handleSave now gets fresh state from store using getState()
|
||||
* - handleSave gets current content from editor via getValue()
|
||||
* - Content is synced to store before saving if it differs
|
||||
*
|
||||
* Since we can't easily test the React/zustand store in node environment,
|
||||
* we test the pure logic that the store uses for dirty state tracking.
|
||||
*/
|
||||
|
||||
describe('File editor dirty state logic', () => {
|
||||
describe('updateTabContent', () => {
|
||||
it('should set isDirty to true when content differs from originalContent', () => {
|
||||
const tab = {
|
||||
content: 'original content',
|
||||
originalContent: 'original content',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
const updated = updateTabContent(tab, 'modified content');
|
||||
|
||||
expect(updated.isDirty).toBe(true);
|
||||
expect(updated.content).toBe('modified content');
|
||||
expect(updated.originalContent).toBe('original content');
|
||||
});
|
||||
|
||||
it('should set isDirty to false when content matches originalContent', () => {
|
||||
const tab = {
|
||||
content: 'original content',
|
||||
originalContent: 'original content',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// First modify it
|
||||
let updated = updateTabContent(tab, 'modified content');
|
||||
expect(updated.isDirty).toBe(true);
|
||||
|
||||
// Now update back to original
|
||||
updated = updateTabContent(updated, 'original content');
|
||||
expect(updated.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty content correctly', () => {
|
||||
const tab = {
|
||||
content: '',
|
||||
originalContent: '',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
const updated = updateTabContent(tab, 'new content');
|
||||
|
||||
expect(updated.isDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markTabSaved', () => {
|
||||
it('should set isDirty to false and update both content and originalContent', () => {
|
||||
const tab = {
|
||||
content: 'original content',
|
||||
originalContent: 'original content',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// First modify
|
||||
let updated = updateTabContent(tab, 'modified content');
|
||||
expect(updated.isDirty).toBe(true);
|
||||
|
||||
// Then save
|
||||
updated = markTabSaved(updated, 'modified content');
|
||||
|
||||
expect(updated.isDirty).toBe(false);
|
||||
expect(updated.content).toBe('modified content');
|
||||
expect(updated.originalContent).toBe('modified content');
|
||||
});
|
||||
|
||||
it('should correctly clear dirty state when save is triggered after edit', () => {
|
||||
// This test simulates the bug scenario:
|
||||
// 1. User edits file -> isDirty = true
|
||||
// 2. User saves -> markTabSaved should set isDirty = false
|
||||
let tab = {
|
||||
content: 'initial',
|
||||
originalContent: 'initial',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// Simulate user editing
|
||||
tab = updateTabContent(tab, 'initial\nnew line');
|
||||
|
||||
// Should be dirty
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Simulate save (with the content that was saved)
|
||||
tab = markTabSaved(tab, 'initial\nnew line');
|
||||
|
||||
// Should NOT be dirty anymore
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('race condition handling', () => {
|
||||
it('should correctly handle updateTabContent after markTabSaved with same content', () => {
|
||||
// This tests the scenario where:
|
||||
// 1. CodeMirror has a pending onChange with content "B"
|
||||
// 2. User presses save when editor shows "B"
|
||||
// 3. markTabSaved is called with "B"
|
||||
// 4. CodeMirror's pending onChange fires with "B" (same content)
|
||||
// Result: isDirty should remain false
|
||||
let tab = {
|
||||
content: 'A',
|
||||
originalContent: 'A',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// User edits to "B"
|
||||
tab = updateTabContent(tab, 'B');
|
||||
|
||||
// Save with "B"
|
||||
tab = markTabSaved(tab, 'B');
|
||||
|
||||
// Late onChange with same content "B"
|
||||
tab = updateTabContent(tab, 'B');
|
||||
|
||||
expect(tab.isDirty).toBe(false);
|
||||
expect(tab.content).toBe('B');
|
||||
});
|
||||
|
||||
it('should correctly handle updateTabContent after markTabSaved with different content', () => {
|
||||
// This tests the scenario where:
|
||||
// 1. CodeMirror has a pending onChange with content "C"
|
||||
// 2. User presses save when store has "B"
|
||||
// 3. markTabSaved is called with "B"
|
||||
// 4. CodeMirror's pending onChange fires with "C" (different content)
|
||||
// Result: isDirty should be true (file changed after save)
|
||||
let tab = {
|
||||
content: 'A',
|
||||
originalContent: 'A',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// User edits to "B"
|
||||
tab = updateTabContent(tab, 'B');
|
||||
|
||||
// Save with "B"
|
||||
tab = markTabSaved(tab, 'B');
|
||||
|
||||
// Late onChange with different content "C"
|
||||
tab = updateTabContent(tab, 'C');
|
||||
|
||||
// File changed after save, so it should be dirty
|
||||
expect(tab.isDirty).toBe(true);
|
||||
expect(tab.content).toBe('C');
|
||||
expect(tab.originalContent).toBe('B');
|
||||
});
|
||||
|
||||
it('should handle rapid edit-save-edit cycle correctly', () => {
|
||||
// Simulate rapid user actions
|
||||
let tab = {
|
||||
content: 'v1',
|
||||
originalContent: 'v1',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// Edit 1
|
||||
tab = updateTabContent(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Save 1
|
||||
tab = markTabSaved(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
|
||||
// Edit 2
|
||||
tab = updateTabContent(tab, 'v3');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Save 2
|
||||
tab = markTabSaved(tab, 'v3');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
|
||||
// Edit 3 (back to v2)
|
||||
tab = updateTabContent(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Save 3
|
||||
tab = markTabSaved(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSave stale closure fix simulation', () => {
|
||||
it('demonstrates the fix: using fresh content instead of closure content', () => {
|
||||
// This test demonstrates why the fix was necessary.
|
||||
// The old handleSave captured activeTab in closure, which could be stale.
|
||||
// The fix gets fresh state from getState() and uses editor.getValue().
|
||||
|
||||
// Simulate store state
|
||||
let storeState = {
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
content: 'A',
|
||||
originalContent: 'A',
|
||||
isDirty: false,
|
||||
},
|
||||
],
|
||||
activeTabId: 'tab-1',
|
||||
};
|
||||
|
||||
// Simulate a "stale closure" capturing the tab state
|
||||
const staleClosureTab = storeState.tabs[0];
|
||||
|
||||
// User edits - store state updates
|
||||
storeState = {
|
||||
...storeState,
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
content: 'B',
|
||||
originalContent: 'A',
|
||||
isDirty: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// OLD BUG: Using stale closure tab would save "A" (old content)
|
||||
const oldBugSavedContent = staleClosureTab!.content;
|
||||
expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B"
|
||||
|
||||
// FIX: Using fresh state from getState() gets correct content
|
||||
const freshTab = storeState.tabs[0];
|
||||
const fixedSavedContent = freshTab!.content;
|
||||
expect(fixedSavedContent).toBe('B'); // Correct!
|
||||
});
|
||||
|
||||
it('demonstrates syncing editor content before save', () => {
|
||||
// This test demonstrates why we need to get content from editor directly.
|
||||
// The store might have stale content if onChange hasn't fired yet.
|
||||
|
||||
// Simulate store state (has old content because onChange hasn't fired)
|
||||
let storeContent = 'A';
|
||||
|
||||
// Editor has newer content (not yet synced to store)
|
||||
const editorContent = 'B';
|
||||
|
||||
// FIX: Use editor content if available, fall back to store content
|
||||
const contentToSave = editorContent ?? storeContent;
|
||||
|
||||
expect(contentToSave).toBe('B'); // Correctly saves editor content
|
||||
|
||||
// Simulate syncing to store before save
|
||||
if (editorContent !== null && editorContent !== storeContent) {
|
||||
storeContent = editorContent;
|
||||
}
|
||||
|
||||
// Now store is synced
|
||||
expect(storeContent).toBe('B');
|
||||
|
||||
// After save, markTabSaved would set originalContent = savedContent
|
||||
// and isDirty = false (if no more changes come in)
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle whitespace-only changes as dirty', () => {
|
||||
let tab = {
|
||||
content: 'hello',
|
||||
originalContent: 'hello',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
tab = updateTabContent(tab, 'hello ');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat CRLF and LF line endings as equivalent (not dirty)', () => {
|
||||
let tab = {
|
||||
content: 'line1\nline2',
|
||||
originalContent: 'line1\nline2',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// CodeMirror normalizes \r\n to \n internally, so content that only
|
||||
// differs by line endings should NOT be considered dirty.
|
||||
tab = updateTabContent(tab, 'line1\r\nline2');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle unicode content correctly', () => {
|
||||
let tab = {
|
||||
content: '你好世界',
|
||||
originalContent: '你好世界',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
tab = updateTabContent(tab, '你好宇宙');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
tab = markTabSaved(tab, '你好宇宙');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle very large content efficiently', () => {
|
||||
// Generate a large string (1MB)
|
||||
const largeOriginal = 'x'.repeat(1024 * 1024);
|
||||
const largeModified = largeOriginal + 'y';
|
||||
|
||||
let tab = {
|
||||
content: largeOriginal,
|
||||
originalContent: largeOriginal,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
tab = updateTabContent(tab, largeModified);
|
||||
|
||||
expect(tab.isDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getMCPServersFromSettings } from '@/lib/settings-helpers.js';
|
||||
import {
|
||||
getMCPServersFromSettings,
|
||||
getProviderById,
|
||||
getProviderByModelId,
|
||||
resolveProviderContext,
|
||||
getAllProviderModels,
|
||||
} from '@/lib/settings-helpers.js';
|
||||
import type { SettingsService } from '@/services/settings-service.js';
|
||||
|
||||
// Mock the logger
|
||||
@@ -286,4 +292,691 @@ describe('settings-helpers.ts', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderById', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return provider when found by ID', async () => {
|
||||
const mockProvider = { id: 'zai-1', name: 'Zai', enabled: true };
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderById('zai-1', mockSettingsService);
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
});
|
||||
|
||||
it('should return undefined when provider not found', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderById('unknown', mockSettingsService);
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return provider even if disabled (caller handles enabled state)', async () => {
|
||||
const mockProvider = { id: 'disabled-1', name: 'Disabled', enabled: false };
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderById('disabled-1', mockSettingsService);
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderByModelId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return provider and modelConfig when found by model ID', async () => {
|
||||
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [mockModel],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig).toEqual(mockModel);
|
||||
});
|
||||
|
||||
it('should resolve mapped Claude model when mapsToClaudeModel is present', async () => {
|
||||
const mockModel = {
|
||||
id: 'custom-model-1',
|
||||
name: 'Custom Model',
|
||||
mapsToClaudeModel: 'sonnet-3-5',
|
||||
};
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [mockModel],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
|
||||
expect(result.resolvedModel).toBeDefined();
|
||||
// resolveModelString('sonnet-3-5') usually returns 'claude-3-5-sonnet-20240620' or similar
|
||||
});
|
||||
|
||||
it('should ignore disabled providers', async () => {
|
||||
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
|
||||
const mockProvider = {
|
||||
id: 'disabled-1',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false,
|
||||
models: [mockModel],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProviderContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should resolve provider by explicit providerId', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'custom-model-1',
|
||||
'provider-1'
|
||||
);
|
||||
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.credentials).toEqual({ anthropicApiKey: 'test-key' });
|
||||
});
|
||||
|
||||
it('should return undefined provider when explicit providerId not found', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'some-model',
|
||||
'unknown-provider'
|
||||
);
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fallback to model-based lookup when providerId not provided', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
|
||||
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig?.id).toBe('custom-model-1');
|
||||
});
|
||||
|
||||
it('should resolve mapsToClaudeModel to actual Claude model', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
id: 'custom-model-1',
|
||||
name: 'Custom Model',
|
||||
mapsToClaudeModel: 'sonnet',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
|
||||
|
||||
// resolveModelString('sonnet') should return a valid Claude model ID
|
||||
expect(result.resolvedModel).toBeDefined();
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should handle empty providers list', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'some-model');
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
expect(result.resolvedModel).toBeUndefined();
|
||||
expect(result.modelConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle missing claudeCompatibleProviders field', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'some-model');
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip disabled providers during fallback lookup', async () => {
|
||||
const disabledProvider = {
|
||||
id: 'disabled-1',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false,
|
||||
models: [{ id: 'model-in-disabled', name: 'Model' }],
|
||||
};
|
||||
const enabledProvider = {
|
||||
id: 'enabled-1',
|
||||
name: 'Enabled Provider',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-in-enabled', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [disabledProvider, enabledProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Should skip the disabled provider and find the model in the enabled one
|
||||
const result = await resolveProviderContext(mockSettingsService, 'model-in-enabled');
|
||||
expect(result.provider?.id).toBe('enabled-1');
|
||||
|
||||
// Should not find model that only exists in disabled provider
|
||||
const result2 = await resolveProviderContext(mockSettingsService, 'model-in-disabled');
|
||||
expect(result2.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should perform case-insensitive model ID matching', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'Custom-Model-1', name: 'Custom Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
|
||||
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig?.id).toBe('Custom-Model-1');
|
||||
});
|
||||
|
||||
it('should return error result on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'some-model');
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
expect(result.credentials).toBeUndefined();
|
||||
expect(result.resolvedModel).toBeUndefined();
|
||||
expect(result.modelConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should persist and load provider config from server settings', async () => {
|
||||
// This test verifies the main bug fix: providers are loaded from server settings
|
||||
const savedProvider = {
|
||||
id: 'saved-provider-1',
|
||||
name: 'Saved Provider',
|
||||
enabled: true,
|
||||
apiKeySource: 'credentials' as const,
|
||||
models: [
|
||||
{
|
||||
id: 'saved-model-1',
|
||||
name: 'Saved Model',
|
||||
mapsToClaudeModel: 'sonnet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [savedProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({
|
||||
anthropicApiKey: 'saved-api-key',
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Simulate loading saved provider config
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'saved-model-1',
|
||||
'saved-provider-1'
|
||||
);
|
||||
|
||||
// Verify the provider is loaded from server settings
|
||||
expect(result.provider).toEqual(savedProvider);
|
||||
expect(result.provider?.id).toBe('saved-provider-1');
|
||||
expect(result.provider?.models).toHaveLength(1);
|
||||
expect(result.credentials?.anthropicApiKey).toBe('saved-api-key');
|
||||
// Verify model mapping is resolved
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should accept custom logPrefix parameter', async () => {
|
||||
// Verify that the logPrefix parameter is accepted (used by facade.ts)
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-1', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Call with custom logPrefix (as facade.ts does)
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'model-1',
|
||||
undefined,
|
||||
'[CustomPrefix]'
|
||||
);
|
||||
|
||||
// Function should work the same with custom prefix
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
});
|
||||
|
||||
// Session restore scenarios - provider.enabled: undefined should be treated as enabled
|
||||
describe('session restore scenarios (enabled: undefined)', () => {
|
||||
it('should treat provider with enabled: undefined as enabled', async () => {
|
||||
// This is the main bug fix: when providers are loaded from settings on session restore,
|
||||
// enabled might be undefined (not explicitly set) and should be treated as enabled
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: undefined, // Not explicitly set - should be treated as enabled
|
||||
models: [{ id: 'model-1', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'model-1');
|
||||
|
||||
// Provider should be found and used even though enabled is undefined
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig?.id).toBe('model-1');
|
||||
});
|
||||
|
||||
it('should use provider by ID when enabled is undefined', async () => {
|
||||
// This tests the explicit providerId lookup with undefined enabled
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: undefined, // Not explicitly set - should be treated as enabled
|
||||
models: [{ id: 'custom-model', name: 'Custom Model', mapsToClaudeModel: 'sonnet' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'custom-model',
|
||||
'provider-1'
|
||||
);
|
||||
|
||||
// Provider should be found and used even though enabled is undefined
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.credentials?.anthropicApiKey).toBe('test-key');
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should find model via fallback in provider with enabled: undefined', async () => {
|
||||
// Test fallback model lookup when provider has undefined enabled
|
||||
const providerWithUndefinedEnabled = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
// enabled is not set (undefined)
|
||||
models: [{ id: 'model-1', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [providerWithUndefinedEnabled],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'model-1');
|
||||
|
||||
expect(result.provider).toEqual(providerWithUndefinedEnabled);
|
||||
expect(result.modelConfig?.id).toBe('model-1');
|
||||
});
|
||||
|
||||
it('should still use provider for connection when model not found in its models array', async () => {
|
||||
// This tests the fix: when providerId is explicitly set and provider is found,
|
||||
// but the model isn't in that provider's models array, we still use that provider
|
||||
// for connection settings (baseUrl, credentials)
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
baseUrl: 'https://custom-api.example.com',
|
||||
models: [{ id: 'other-model', name: 'Other Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'unknown-model', // Model not in provider's models array
|
||||
'provider-1'
|
||||
);
|
||||
|
||||
// Provider should still be returned for connection settings
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
// modelConfig should be undefined since the model wasn't found
|
||||
expect(result.modelConfig).toBeUndefined();
|
||||
// resolvedModel should be undefined since no mapping was found
|
||||
expect(result.resolvedModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fallback to find modelConfig in other providers when not in explicit providerId provider', async () => {
|
||||
// When providerId is set and provider is found, but model isn't there,
|
||||
// we should still search for modelConfig in other providers
|
||||
const provider1 = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
baseUrl: 'https://provider1.example.com',
|
||||
models: [{ id: 'provider1-model', name: 'Provider 1 Model' }],
|
||||
};
|
||||
const provider2 = {
|
||||
id: 'provider-2',
|
||||
name: 'Provider 2',
|
||||
enabled: true,
|
||||
baseUrl: 'https://provider2.example.com',
|
||||
models: [
|
||||
{
|
||||
id: 'shared-model',
|
||||
name: 'Shared Model',
|
||||
mapsToClaudeModel: 'sonnet',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [provider1, provider2],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'shared-model', // This model is in provider-2, not provider-1
|
||||
'provider-1' // But we explicitly want to use provider-1
|
||||
);
|
||||
|
||||
// Provider should still be provider-1 (for connection settings)
|
||||
expect(result.provider).toEqual(provider1);
|
||||
// But modelConfig should be found from provider-2
|
||||
expect(result.modelConfig?.id).toBe('shared-model');
|
||||
// And the model mapping should be resolved
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should handle multiple providers with mixed enabled states', async () => {
|
||||
// Test the full session restore scenario with multiple providers
|
||||
const providers = [
|
||||
{
|
||||
id: 'provider-1',
|
||||
name: 'First Provider',
|
||||
enabled: undefined, // Undefined after restore
|
||||
models: [{ id: 'model-a', name: 'Model A' }],
|
||||
},
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: 'Second Provider',
|
||||
// enabled field missing entirely
|
||||
models: [{ id: 'model-b', name: 'Model B', mapsToClaudeModel: 'opus' }],
|
||||
},
|
||||
{
|
||||
id: 'provider-3',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false, // Explicitly disabled
|
||||
models: [{ id: 'model-c', name: 'Model C' }],
|
||||
},
|
||||
];
|
||||
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: providers,
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Provider 1 should work (enabled: undefined)
|
||||
const result1 = await resolveProviderContext(mockSettingsService, 'model-a', 'provider-1');
|
||||
expect(result1.provider?.id).toBe('provider-1');
|
||||
expect(result1.modelConfig?.id).toBe('model-a');
|
||||
|
||||
// Provider 2 should work (enabled field missing)
|
||||
const result2 = await resolveProviderContext(mockSettingsService, 'model-b', 'provider-2');
|
||||
expect(result2.provider?.id).toBe('provider-2');
|
||||
expect(result2.modelConfig?.id).toBe('model-b');
|
||||
expect(result2.resolvedModel).toContain('claude');
|
||||
|
||||
// Provider 3 with explicit providerId IS returned even if disabled
|
||||
// (caller handles enabled state check)
|
||||
const result3 = await resolveProviderContext(mockSettingsService, 'model-c', 'provider-3');
|
||||
// Provider is found but modelConfig won't be found since disabled providers
|
||||
// skip model lookup in their models array
|
||||
expect(result3.provider).toEqual(providers[2]);
|
||||
expect(result3.modelConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllProviderModels', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return all models from enabled providers', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [
|
||||
{ id: 'model-1', name: 'Model 1' },
|
||||
{ id: 'model-2', name: 'Model 2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: 'Provider 2',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-3', name: 'Model 3' }],
|
||||
},
|
||||
];
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].providerId).toBe('provider-1');
|
||||
expect(result[0].model.id).toBe('model-1');
|
||||
expect(result[2].providerId).toBe('provider-2');
|
||||
});
|
||||
|
||||
it('should filter out disabled providers', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'enabled-1',
|
||||
name: 'Enabled Provider',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-1', name: 'Model 1' }],
|
||||
},
|
||||
{
|
||||
id: 'disabled-1',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false,
|
||||
models: [{ id: 'model-2', name: 'Model 2' }],
|
||||
},
|
||||
];
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].providerId).toBe('enabled-1');
|
||||
});
|
||||
|
||||
it('should return empty array when no providers configured', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing claudeCompatibleProviders field', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle provider with no models', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: 'Provider 2',
|
||||
enabled: true,
|
||||
// no models field
|
||||
},
|
||||
];
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
calculateReasoningTimeout,
|
||||
REASONING_TIMEOUT_MULTIPLIERS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
validateBareModelId,
|
||||
} from '@automaker/types';
|
||||
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
@@ -455,4 +456,19 @@ describe('codex-provider.ts', () => {
|
||||
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBareModelId integration', () => {
|
||||
it('should allow codex- prefixed models for Codex provider with expectedProvider="codex"', () => {
|
||||
expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow();
|
||||
expect(() =>
|
||||
validateBareModelId('codex-gpt-5.1-codex-max', 'CodexProvider', 'codex')
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject other provider prefixes for Codex provider', () => {
|
||||
expect(() => validateBareModelId('cursor-gpt-4', 'CodexProvider', 'codex')).toThrow();
|
||||
expect(() => validateBareModelId('gemini-2.5-flash', 'CodexProvider', 'codex')).toThrow();
|
||||
expect(() => validateBareModelId('copilot-gpt-4', 'CodexProvider', 'codex')).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,13 +331,15 @@ describe('copilot-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_end event', () => {
|
||||
it('should normalize tool.execution_complete event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
result: 'file content',
|
||||
success: true,
|
||||
result: {
|
||||
content: 'file content',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -357,23 +359,85 @@ describe('copilot-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_end with error', () => {
|
||||
it('should handle tool.execution_complete with error', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolName: 'bash',
|
||||
toolCallId: 'call-456',
|
||||
error: 'Command failed',
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Command failed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-456',
|
||||
content: '[ERROR] Command failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_complete with empty result', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolCallId: 'call-789',
|
||||
success: true,
|
||||
result: {
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-789',
|
||||
content: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_complete with missing result', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolCallId: 'call-999',
|
||||
success: true,
|
||||
// No result field
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-999',
|
||||
content: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_complete with error code', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolCallId: 'call-567',
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Permission denied',
|
||||
code: 'EACCES',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-567',
|
||||
content: '[ERROR] Permission denied (EACCES)',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.idle to success result', () => {
|
||||
const event = { type: 'session.idle' };
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { CursorProvider } from '@/providers/cursor-provider.js';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
|
||||
describe('cursor-provider.ts', () => {
|
||||
describe('buildCliArgs', () => {
|
||||
@@ -154,4 +155,81 @@ describe('cursor-provider.ts', () => {
|
||||
expect(msg!.subtype).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cursor Gemini models support', () => {
|
||||
let provider: CursorProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = Object.create(CursorProvider.prototype) as CursorProvider & {
|
||||
cliPath?: string;
|
||||
};
|
||||
provider.cliPath = '/usr/local/bin/cursor-agent';
|
||||
});
|
||||
|
||||
describe('buildCliArgs with Cursor Gemini models', () => {
|
||||
it('should handle cursor-gemini-3-pro model', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Write a function',
|
||||
model: 'gemini-3-pro', // Bare model ID after stripping cursor- prefix
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const modelIndex = args.indexOf('--model');
|
||||
expect(modelIndex).toBeGreaterThan(-1);
|
||||
expect(args[modelIndex + 1]).toBe('gemini-3-pro');
|
||||
});
|
||||
|
||||
it('should handle cursor-gemini-3-flash model', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Quick task',
|
||||
model: 'gemini-3-flash', // Bare model ID after stripping cursor- prefix
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const modelIndex = args.indexOf('--model');
|
||||
expect(modelIndex).toBeGreaterThan(-1);
|
||||
expect(args[modelIndex + 1]).toBe('gemini-3-flash');
|
||||
});
|
||||
|
||||
it('should include --resume with Cursor Gemini models when sdkSessionId is provided', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Continue task',
|
||||
model: 'gemini-3-pro',
|
||||
cwd: '/tmp/project',
|
||||
sdkSessionId: 'cursor-gemini-session-123',
|
||||
});
|
||||
|
||||
const resumeIndex = args.indexOf('--resume');
|
||||
expect(resumeIndex).toBeGreaterThan(-1);
|
||||
expect(args[resumeIndex + 1]).toBe('cursor-gemini-session-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBareModelId with Cursor Gemini models', () => {
|
||||
it('should allow gemini- prefixed models for Cursor provider with expectedProvider="cursor"', () => {
|
||||
// This is the key fix - Cursor Gemini models have bare IDs like "gemini-3-pro"
|
||||
expect(() => validateBareModelId('gemini-3-pro', 'CursorProvider', 'cursor')).not.toThrow();
|
||||
expect(() =>
|
||||
validateBareModelId('gemini-3-flash', 'CursorProvider', 'cursor')
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should still reject other provider prefixes for Cursor provider', () => {
|
||||
expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow();
|
||||
expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow();
|
||||
expect(() => validateBareModelId('opencode-gpt-4', 'CursorProvider', 'cursor')).toThrow();
|
||||
});
|
||||
|
||||
it('should accept cursor- prefixed models when expectedProvider is "cursor" (for double-prefix validation)', () => {
|
||||
// Note: When expectedProvider="cursor", we skip the cursor- prefix check
|
||||
// This is intentional because the validation happens AFTER prefix stripping
|
||||
// So if cursor-gemini-3-pro reaches validateBareModelId with expectedProvider="cursor",
|
||||
// it means the prefix was NOT properly stripped, but we skip it anyway
|
||||
// since we're checking if the Cursor provider itself can receive cursor- prefixed models
|
||||
expect(() =>
|
||||
validateBareModelId('cursor-gemini-3-pro', 'CursorProvider', 'cursor')
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||
import type { ProviderMessage } from '@automaker/types';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
|
||||
describe('gemini-provider.ts', () => {
|
||||
let provider: GeminiProvider;
|
||||
@@ -253,4 +254,19 @@ describe('gemini-provider.ts', () => {
|
||||
expect(msg.subtype).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBareModelId integration', () => {
|
||||
it('should allow gemini- prefixed models for Gemini provider with expectedProvider="gemini"', () => {
|
||||
expect(() =>
|
||||
validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini')
|
||||
).not.toThrow();
|
||||
expect(() => validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject other provider prefixes for Gemini provider', () => {
|
||||
expect(() => validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
|
||||
expect(() => validateBareModelId('codex-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
|
||||
expect(() => validateBareModelId('copilot-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Tests for default fields applied to features created by parseAndCreateFeatures
|
||||
*
|
||||
* Verifies that auto-created features include planningMode: 'skip',
|
||||
* requirePlanApproval: false, and dependencies: [].
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import path from 'path';
|
||||
|
||||
// Use vi.hoisted to create mock functions that can be referenced in vi.mock factories
|
||||
const { mockMkdir, mockAtomicWriteJson, mockExtractJsonWithArray, mockCreateNotification } =
|
||||
vi.hoisted(() => ({
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockAtomicWriteJson: vi.fn().mockResolvedValue(undefined),
|
||||
mockExtractJsonWithArray: vi.fn(),
|
||||
mockCreateNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
mkdir: mockMkdir,
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
atomicWriteJson: mockAtomicWriteJson,
|
||||
DEFAULT_BACKUP_COUNT: 3,
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeaturesDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker', 'features')),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/json-extractor.js', () => ({
|
||||
extractJsonWithArray: mockExtractJsonWithArray,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/notification-service.js', () => ({
|
||||
getNotificationService: vi.fn(() => ({
|
||||
createNotification: mockCreateNotification,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { parseAndCreateFeatures } from '../../../../src/routes/app-spec/parse-and-create-features.js';
|
||||
|
||||
describe('parseAndCreateFeatures - default fields', () => {
|
||||
const mockEvents = {
|
||||
emit: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const projectPath = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should set planningMode to "skip" on created features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
priority: 1,
|
||||
complexity: 'simple',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(1);
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.planningMode).toBe('skip');
|
||||
});
|
||||
|
||||
it('should set requirePlanApproval to false on created features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should set dependencies to empty array when not provided', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve dependencies when provided by the parser', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
dependencies: ['feature-0'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.dependencies).toEqual(['feature-0']);
|
||||
});
|
||||
|
||||
it('should apply all default fields consistently across multiple features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Feature 1',
|
||||
description: 'First feature',
|
||||
},
|
||||
{
|
||||
id: 'feature-2',
|
||||
title: 'Feature 2',
|
||||
description: 'Second feature',
|
||||
dependencies: ['feature-1'],
|
||||
},
|
||||
{
|
||||
id: 'feature-3',
|
||||
title: 'Feature 3',
|
||||
description: 'Third feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(3);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[i][1];
|
||||
expect(writtenData.planningMode, `feature ${i + 1} planningMode`).toBe('skip');
|
||||
expect(writtenData.requirePlanApproval, `feature ${i + 1} requirePlanApproval`).toBe(false);
|
||||
expect(Array.isArray(writtenData.dependencies), `feature ${i + 1} dependencies`).toBe(true);
|
||||
}
|
||||
|
||||
// Feature 2 should have its explicit dependency preserved
|
||||
expect(mockAtomicWriteJson.mock.calls[1][1].dependencies).toEqual(['feature-1']);
|
||||
// Features 1 and 3 should have empty arrays
|
||||
expect(mockAtomicWriteJson.mock.calls[0][1].dependencies).toEqual([]);
|
||||
expect(mockAtomicWriteJson.mock.calls[2][1].dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set status to "backlog" on all created features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.status).toBe('backlog');
|
||||
});
|
||||
|
||||
it('should include createdAt and updatedAt timestamps', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.createdAt).toBeDefined();
|
||||
expect(writtenData.updatedAt).toBeDefined();
|
||||
// Should be valid ISO date strings
|
||||
expect(new Date(writtenData.createdAt).toISOString()).toBe(writtenData.createdAt);
|
||||
expect(new Date(writtenData.updatedAt).toISOString()).toBe(writtenData.updatedAt);
|
||||
});
|
||||
|
||||
it('should use default values for optional fields not provided', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-minimal',
|
||||
title: 'Minimal Feature',
|
||||
description: 'Only required fields',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.category).toBe('Uncategorized');
|
||||
expect(writtenData.priority).toBe(2);
|
||||
expect(writtenData.complexity).toBe('moderate');
|
||||
expect(writtenData.dependencies).toEqual([]);
|
||||
expect(writtenData.planningMode).toBe('skip');
|
||||
expect(writtenData.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should emit success event after creating features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Feature 1',
|
||||
description: 'First',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'spec-regeneration:event',
|
||||
expect.objectContaining({
|
||||
type: 'spec_regeneration_complete',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit error event when no valid JSON is found', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue(null);
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'invalid content', mockEvents);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'spec-regeneration:event',
|
||||
expect.objectContaining({
|
||||
type: 'spec_regeneration_error',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
149
apps/server/tests/unit/routes/backlog-plan/apply.test.ts
Normal file
149
apps/server/tests/unit/routes/backlog-plan/apply.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockGetAll, mockCreate, mockUpdate, mockDelete, mockClearBacklogPlan } = vi.hoisted(() => ({
|
||||
mockGetAll: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockClearBacklogPlan: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/feature-loader.js', () => ({
|
||||
FeatureLoader: class {
|
||||
getAll = mockGetAll;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/backlog-plan/common.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
clearBacklogPlan: mockClearBacklogPlan,
|
||||
getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
logError: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createApplyHandler } from '@/routes/backlog-plan/routes/apply.js';
|
||||
|
||||
function createMockRes() {
|
||||
const res: {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
};
|
||||
res.status.mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('createApplyHandler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetAll.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue({ id: 'feature-created' });
|
||||
mockUpdate.mockResolvedValue({});
|
||||
mockDelete.mockResolvedValue(true);
|
||||
mockClearBacklogPlan.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('applies default feature model and planning settings when backlog plan additions omit them', async () => {
|
||||
const settingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
defaultFeatureModel: { model: 'codex-gpt-5.2-codex', reasoningEffort: 'high' },
|
||||
defaultPlanningMode: 'spec',
|
||||
defaultRequirePlanApproval: true,
|
||||
}),
|
||||
getProjectSettings: vi.fn().mockResolvedValue({}),
|
||||
} as any;
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
projectPath: '/tmp/project',
|
||||
plan: {
|
||||
changes: [
|
||||
{
|
||||
type: 'add',
|
||||
feature: {
|
||||
id: 'feature-from-plan',
|
||||
title: 'Created from plan',
|
||||
description: 'desc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const res = createMockRes();
|
||||
|
||||
await createApplyHandler(settingsService)(req, res as any);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
'/tmp/project',
|
||||
expect.objectContaining({
|
||||
model: 'codex-gpt-5.2-codex',
|
||||
reasoningEffort: 'high',
|
||||
planningMode: 'spec',
|
||||
requirePlanApproval: true,
|
||||
})
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses project default feature model override and enforces no approval for skip mode', async () => {
|
||||
const settingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
defaultFeatureModel: { model: 'claude-opus' },
|
||||
defaultPlanningMode: 'skip',
|
||||
defaultRequirePlanApproval: true,
|
||||
}),
|
||||
getProjectSettings: vi.fn().mockResolvedValue({
|
||||
defaultFeatureModel: {
|
||||
model: 'GLM-4.7',
|
||||
providerId: 'provider-glm',
|
||||
thinkingLevel: 'adaptive',
|
||||
},
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
projectPath: '/tmp/project',
|
||||
plan: {
|
||||
changes: [
|
||||
{
|
||||
type: 'add',
|
||||
feature: {
|
||||
id: 'feature-from-plan',
|
||||
title: 'Created from plan',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const res = createMockRes();
|
||||
|
||||
await createApplyHandler(settingsService)(req, res as any);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
'/tmp/project',
|
||||
expect.objectContaining({
|
||||
model: 'GLM-4.7',
|
||||
providerId: 'provider-glm',
|
||||
thinkingLevel: 'adaptive',
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/home/user/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Implement login feature',
|
||||
description: 'Add user authentication with OAuth',
|
||||
},
|
||||
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/home/user/other-project',
|
||||
projectName: 'other-project',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Fix navigation bug',
|
||||
description: undefined,
|
||||
},
|
||||
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: `/project-${i}`,
|
||||
projectName: `project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
|
||||
provider: 'claude',
|
||||
title: `Feature ${i}`,
|
||||
description: `Description ${i}`,
|
||||
}));
|
||||
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/workspace/project-alpha',
|
||||
projectName: 'project-alpha',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Feature A',
|
||||
description: 'In project alpha',
|
||||
},
|
||||
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/workspace/project-beta',
|
||||
projectName: 'project-beta',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Feature B',
|
||||
description: 'In project beta',
|
||||
},
|
||||
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
|
||||
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
|
||||
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
|
||||
});
|
||||
|
||||
it('should include model and provider information for running agents', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'feature-claude',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Claude Feature',
|
||||
description: 'Using Claude model',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-codex',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Codex Feature',
|
||||
description: 'Using Codex model',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-cursor',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: false,
|
||||
model: 'cursor-auto',
|
||||
provider: 'cursor',
|
||||
title: 'Cursor Feature',
|
||||
description: 'Using Cursor model',
|
||||
},
|
||||
];
|
||||
|
||||
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].model).toBe('claude-sonnet-4-20250514');
|
||||
expect(response.runningAgents[0].provider).toBe('claude');
|
||||
expect(response.runningAgents[1].model).toBe('codex-gpt-5.1');
|
||||
expect(response.runningAgents[1].provider).toBe('codex');
|
||||
expect(response.runningAgents[2].model).toBe('cursor-auto');
|
||||
expect(response.runningAgents[2].provider).toBe('cursor');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
/**
|
||||
* Tests for worktree list endpoint handling of detached HEAD state.
|
||||
*
|
||||
* When a worktree is in detached HEAD state (e.g., during a rebase),
|
||||
* `git worktree list --porcelain` outputs "detached" instead of
|
||||
* "branch refs/heads/...". Previously, these worktrees were silently
|
||||
* dropped from the response because the parser required both path AND branch.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock all external dependencies before importing the module under test
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/git.js', () => ({
|
||||
execGitCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/git-utils', () => ({
|
||||
isGitRepo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/types', () => ({
|
||||
validatePRState: vi.fn((state: string) => state),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/worktree-metadata.js', () => ({
|
||||
readAllWorktreeMetadata: vi.fn(async () => new Map()),
|
||||
updateWorktreePRInfo: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/worktree/common.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: vi.fn((e: Error) => e?.message || 'Unknown error'),
|
||||
logError: vi.fn(),
|
||||
normalizePath: vi.fn((p: string) => p),
|
||||
execEnv: {},
|
||||
isGhCliAvailable: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/routes/github/routes/check-github-remote.js', () => ({
|
||||
checkGitHubRemote: vi.fn().mockResolvedValue({ hasGitHubRemote: false }),
|
||||
}));
|
||||
|
||||
import { createListHandler } from '@/routes/worktree/routes/list.js';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { execGitCommand } from '@/lib/git.js';
|
||||
import { readAllWorktreeMetadata, updateWorktreePRInfo } from '@/lib/worktree-metadata.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { isGhCliAvailable, normalizePath, getErrorMessage } from '@/routes/worktree/common.js';
|
||||
import { checkGitHubRemote } from '@/routes/github/routes/check-github-remote.js';
|
||||
|
||||
/**
|
||||
* Set up execGitCommand mock (list handler uses this via lib/git.js, not child_process.exec).
|
||||
*/
|
||||
function setupExecGitCommandMock(options: {
|
||||
porcelainOutput: string;
|
||||
projectBranch?: string;
|
||||
gitDirs?: Record<string, string>;
|
||||
worktreeBranches?: Record<string, string>;
|
||||
}) {
|
||||
const { porcelainOutput, projectBranch = 'main', gitDirs = {}, worktreeBranches = {} } = options;
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') {
|
||||
return porcelainOutput;
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
if (worktreeBranches[cwd] !== undefined) {
|
||||
return worktreeBranches[cwd] + '\n';
|
||||
}
|
||||
return projectBranch + '\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
if (cwd && gitDirs[cwd]) {
|
||||
return gitDirs[cwd] + '\n';
|
||||
}
|
||||
throw new Error('not a git directory');
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
|
||||
return 'HEAD\n';
|
||||
}
|
||||
if (args[0] === 'worktree' && args[1] === 'prune') {
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'status' && args[1] === '--porcelain') {
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'diff' && args[1] === '--name-only' && args[2] === '--diff-filter=U') {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
describe('worktree list - detached HEAD handling', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
|
||||
// Re-establish mock implementations cleared by mockReset/clearAllMocks
|
||||
vi.mocked(isGitRepo).mockResolvedValue(true);
|
||||
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(new Map());
|
||||
vi.mocked(isGhCliAvailable).mockResolvedValue(false);
|
||||
vi.mocked(checkGitHubRemote).mockResolvedValue({ hasGitHubRemote: false });
|
||||
vi.mocked(normalizePath).mockImplementation((p: string) => p);
|
||||
vi.mocked(getErrorMessage).mockImplementation(
|
||||
(e: unknown) => (e as Error)?.message || 'Unknown error'
|
||||
);
|
||||
|
||||
// Default: all paths exist
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
// Default: .worktrees directory doesn't exist (no scan via readdir)
|
||||
vi.mocked(secureFs.readdir).mockRejectedValue(new Error('ENOENT'));
|
||||
// Default: readFile fails
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
// Default execGitCommand so list handler gets valid porcelain/branch output (vitest clearMocks resets implementations)
|
||||
setupExecGitCommandMock({
|
||||
porcelainOutput: 'worktree /project\nbranch refs/heads/main\n\n',
|
||||
projectBranch: 'main',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: set up execGitCommand mock for the list handler.
|
||||
* Worktree-specific behavior can be customized via the options parameter.
|
||||
*/
|
||||
function setupStandardExec(options: {
|
||||
porcelainOutput: string;
|
||||
projectBranch?: string;
|
||||
/** Map of worktree path -> git-dir path */
|
||||
gitDirs?: Record<string, string>;
|
||||
/** Map of worktree cwd -> branch for `git branch --show-current` */
|
||||
worktreeBranches?: Record<string, string>;
|
||||
}) {
|
||||
setupExecGitCommandMock(options);
|
||||
}
|
||||
|
||||
/** Suppress .worktrees dir scan by making access throw for the .worktrees dir. */
|
||||
function disableWorktreesScan() {
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
// Block only the .worktrees dir access check in scanWorktreesDirectory
|
||||
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
// All other paths exist
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
describe('porcelain parser', () => {
|
||||
it('should include normal worktrees with branch lines', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/feature-a',
|
||||
'branch refs/heads/feature-a',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
success: boolean;
|
||||
worktrees: Array<{ branch: string; path: string; isMain: boolean; hasWorktree: boolean }>;
|
||||
};
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.worktrees).toHaveLength(2);
|
||||
expect(response.worktrees[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project',
|
||||
branch: 'main',
|
||||
isMain: true,
|
||||
hasWorktree: true,
|
||||
})
|
||||
);
|
||||
expect(response.worktrees[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/feature-a',
|
||||
branch: 'feature-a',
|
||||
isMain: false,
|
||||
hasWorktree: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include worktrees with detached HEAD and recover branch from rebase-merge state', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/rebasing-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// rebase-merge/head-name returns the branch being rebased
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/my-rebasing-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string; isCurrent: boolean }>;
|
||||
};
|
||||
expect(response.worktrees).toHaveLength(2);
|
||||
expect(response.worktrees[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/rebasing-wt',
|
||||
branch: 'feature/my-rebasing-branch',
|
||||
isMain: false,
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include worktrees with detached HEAD and recover branch from rebase-apply state', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/apply-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/apply-wt': '/project/.worktrees/apply-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// rebase-merge doesn't exist, but rebase-apply does
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-apply/head-name')) {
|
||||
return 'refs/heads/feature/apply-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const detachedWt = response.worktrees.find((w) => w.path === '/project/.worktrees/apply-wt');
|
||||
expect(detachedWt).toBeDefined();
|
||||
expect(detachedWt!.branch).toBe('feature/apply-branch');
|
||||
});
|
||||
|
||||
it('should show merge conflict worktrees normally since merge does not detach HEAD', async () => {
|
||||
// During a merge conflict, HEAD stays on the branch, so `git worktree list --porcelain`
|
||||
// still outputs `branch refs/heads/...`. This test verifies merge conflicts don't
|
||||
// trigger the detached HEAD recovery path.
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/merge-wt',
|
||||
'branch refs/heads/feature/merge-branch',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const mergeWt = response.worktrees.find((w) => w.path === '/project/.worktrees/merge-wt');
|
||||
expect(mergeWt).toBeDefined();
|
||||
expect(mergeWt!.branch).toBe('feature/merge-branch');
|
||||
});
|
||||
|
||||
it('should fall back to (detached) when all branch recovery methods fail', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/unknown-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
worktreeBranches: {
|
||||
'/project/.worktrees/unknown-wt': '', // empty = no branch
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// All readFile calls fail (no gitDirs so rev-parse --git-dir will throw)
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const detachedWt = response.worktrees.find(
|
||||
(w) => w.path === '/project/.worktrees/unknown-wt'
|
||||
);
|
||||
expect(detachedWt).toBeDefined();
|
||||
expect(detachedWt!.branch).toBe('(detached)');
|
||||
});
|
||||
|
||||
it('should not include detached worktree when directory does not exist on disk', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/deleted-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// The deleted worktree doesn't exist on disk
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (pathStr.includes('deleted-wt')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
// Only the main worktree should be present
|
||||
expect(response.worktrees).toHaveLength(1);
|
||||
expect(response.worktrees[0].path).toBe('/project');
|
||||
});
|
||||
|
||||
it('should set isCurrent to false for detached worktrees even if recovered branch matches current branch', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/rebasing-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
// currentBranch for project is 'feature/my-branch'
|
||||
projectBranch: 'feature/my-branch',
|
||||
gitDirs: {
|
||||
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// Recovery returns the same branch as currentBranch
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/my-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; isCurrent: boolean; path: string }>;
|
||||
};
|
||||
const detachedWt = response.worktrees.find(
|
||||
(w) => w.path === '/project/.worktrees/rebasing-wt'
|
||||
);
|
||||
expect(detachedWt).toBeDefined();
|
||||
// Detached worktrees should always have isCurrent=false
|
||||
expect(detachedWt!.isCurrent).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle mixed normal and detached worktrees', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/normal-wt',
|
||||
'branch refs/heads/feature-normal',
|
||||
'',
|
||||
'worktree /project/.worktrees/rebasing-wt',
|
||||
'detached',
|
||||
'',
|
||||
'worktree /project/.worktrees/another-normal',
|
||||
'branch refs/heads/feature-other',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/rebasing\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string; isMain: boolean }>;
|
||||
};
|
||||
expect(response.worktrees).toHaveLength(4);
|
||||
expect(response.worktrees[0]).toEqual(
|
||||
expect.objectContaining({ path: '/project', branch: 'main', isMain: true })
|
||||
);
|
||||
expect(response.worktrees[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/normal-wt',
|
||||
branch: 'feature-normal',
|
||||
isMain: false,
|
||||
})
|
||||
);
|
||||
expect(response.worktrees[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/rebasing-wt',
|
||||
branch: 'feature/rebasing',
|
||||
isMain: false,
|
||||
})
|
||||
);
|
||||
expect(response.worktrees[3]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/another-normal',
|
||||
branch: 'feature-other',
|
||||
isMain: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly advance isFirst flag past detached worktrees', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/detached-wt',
|
||||
'detached',
|
||||
'',
|
||||
'worktree /project/.worktrees/normal-wt',
|
||||
'branch refs/heads/feature-x',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
disableWorktreesScan();
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; isMain: boolean }>;
|
||||
};
|
||||
expect(response.worktrees).toHaveLength(3);
|
||||
expect(response.worktrees[0].isMain).toBe(true); // main
|
||||
expect(response.worktrees[1].isMain).toBe(false); // detached
|
||||
expect(response.worktrees[2].isMain).toBe(false); // normal
|
||||
});
|
||||
|
||||
it('should not add removed detached worktrees to removedWorktrees list', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/gone-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// The detached worktree doesn't exist on disk
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (pathStr.includes('gone-wt')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string }>;
|
||||
removedWorktrees?: Array<{ path: string; branch: string }>;
|
||||
};
|
||||
// Should not be in removed list since we don't know the branch
|
||||
expect(response.removedWorktrees).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should strip refs/heads/ prefix from recovered branch name', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/wt1',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/wt1': '/project/.worktrees/wt1/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/my-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const wt = response.worktrees.find((w) => w.path === '/project/.worktrees/wt1');
|
||||
expect(wt).toBeDefined();
|
||||
// Should be 'my-branch', not 'refs/heads/my-branch'
|
||||
expect(wt!.branch).toBe('my-branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanWorktreesDirectory with detached HEAD recovery', () => {
|
||||
it('should recover branch for discovered worktrees with detached HEAD', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return 'worktree /project\nbranch refs/heads/main\n\n';
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project' ? 'main\n' : '\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
|
||||
return 'HEAD\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
return '/project/.worktrees/orphan-wt/.git\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// .worktrees directory exists and has an orphan worktree
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readdir).mockResolvedValue([
|
||||
{ name: 'orphan-wt', isDirectory: () => true, isFile: () => false } as any,
|
||||
]);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
|
||||
// readFile returns branch from rebase-merge/head-name
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/orphan-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
|
||||
const orphanWt = response.worktrees.find((w) => w.path === '/project/.worktrees/orphan-wt');
|
||||
expect(orphanWt).toBeDefined();
|
||||
expect(orphanWt!.branch).toBe('feature/orphan-branch');
|
||||
});
|
||||
|
||||
it('should skip discovered worktrees when all branch detection fails', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return 'worktree /project\nbranch refs/heads/main\n\n';
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project' ? 'main\n' : '\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
|
||||
return 'HEAD\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
throw new Error('not a git dir');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readdir).mockResolvedValue([
|
||||
{ name: 'broken-wt', isDirectory: () => true, isFile: () => false } as any,
|
||||
]);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
|
||||
// Only main worktree should be present
|
||||
expect(response.worktrees).toHaveLength(1);
|
||||
expect(response.worktrees[0].branch).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR tracking precedence', () => {
|
||||
it('should keep manually tracked PR from metadata when branch PR differs', async () => {
|
||||
req.body = { projectPath: '/project', includeDetails: true };
|
||||
|
||||
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'feature-a',
|
||||
{
|
||||
branch: 'feature-a',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
pr: {
|
||||
number: 99,
|
||||
url: 'https://github.com/org/repo/pull/99',
|
||||
title: 'Manual override PR',
|
||||
state: 'OPEN',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
|
||||
vi.mocked(checkGitHubRemote).mockResolvedValue({
|
||||
hasGitHubRemote: true,
|
||||
owner: 'org',
|
||||
repo: 'repo',
|
||||
});
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (
|
||||
pathStr.includes('MERGE_HEAD') ||
|
||||
pathStr.includes('rebase-merge') ||
|
||||
pathStr.includes('rebase-apply') ||
|
||||
pathStr.includes('CHERRY_PICK_HEAD')
|
||||
) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
throw new Error('no git dir');
|
||||
}
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/feature-a',
|
||||
'branch refs/heads/feature-a',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project' ? 'main\n' : 'feature-a\n';
|
||||
}
|
||||
if (args[0] === 'status' && args[1] === '--porcelain') {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
(exec as unknown as Mock).mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
_opts: unknown,
|
||||
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
|
||||
) => {
|
||||
const cb = typeof _opts === 'function' ? _opts : callback!;
|
||||
if (cmd.includes('gh pr list')) {
|
||||
cb(null, {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Branch PR',
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
state: 'OPEN',
|
||||
headRefName: 'feature-a',
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
stderr: '',
|
||||
});
|
||||
} else {
|
||||
cb(null, { stdout: '', stderr: '' });
|
||||
}
|
||||
}
|
||||
);
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; pr?: { number: number; title: string } }>;
|
||||
};
|
||||
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
|
||||
expect(featureWorktree?.pr?.number).toBe(99);
|
||||
expect(featureWorktree?.pr?.title).toBe('Manual override PR');
|
||||
});
|
||||
|
||||
it('should prefer GitHub PR when it matches metadata number and sync updated fields', async () => {
|
||||
req.body = { projectPath: '/project-2', includeDetails: true };
|
||||
|
||||
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'feature-a',
|
||||
{
|
||||
branch: 'feature-a',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
pr: {
|
||||
number: 42,
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
title: 'Old title',
|
||||
state: 'OPEN',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
|
||||
vi.mocked(checkGitHubRemote).mockResolvedValue({
|
||||
hasGitHubRemote: true,
|
||||
owner: 'org',
|
||||
repo: 'repo',
|
||||
});
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (
|
||||
pathStr.includes('MERGE_HEAD') ||
|
||||
pathStr.includes('rebase-merge') ||
|
||||
pathStr.includes('rebase-apply') ||
|
||||
pathStr.includes('CHERRY_PICK_HEAD')
|
||||
) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
throw new Error('no git dir');
|
||||
}
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return [
|
||||
'worktree /project-2',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project-2/.worktrees/feature-a',
|
||||
'branch refs/heads/feature-a',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project-2' ? 'main\n' : 'feature-a\n';
|
||||
}
|
||||
if (args[0] === 'status' && args[1] === '--porcelain') {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
(exec as unknown as Mock).mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
_opts: unknown,
|
||||
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
|
||||
) => {
|
||||
const cb = typeof _opts === 'function' ? _opts : callback!;
|
||||
if (cmd.includes('gh pr list')) {
|
||||
cb(null, {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'New title from GitHub',
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
state: 'MERGED',
|
||||
headRefName: 'feature-a',
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
stderr: '',
|
||||
});
|
||||
} else {
|
||||
cb(null, { stdout: '', stderr: '' });
|
||||
}
|
||||
}
|
||||
);
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; pr?: { number: number; title: string; state: string } }>;
|
||||
};
|
||||
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
|
||||
expect(featureWorktree?.pr?.number).toBe(42);
|
||||
expect(featureWorktree?.pr?.title).toBe('New title from GitHub');
|
||||
expect(featureWorktree?.pr?.state).toBe('MERGED');
|
||||
expect(vi.mocked(updateWorktreePRInfo)).toHaveBeenCalledWith(
|
||||
'/project-2',
|
||||
'feature-a',
|
||||
expect.objectContaining({
|
||||
number: 42,
|
||||
title: 'New title from GitHub',
|
||||
state: 'MERGED',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
446
apps/server/tests/unit/services/agent-executor-summary.test.ts
Normal file
446
apps/server/tests/unit/services/agent-executor-summary.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { AgentExecutor } from '../../../src/services/agent-executor.js';
|
||||
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
|
||||
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
|
||||
import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js';
|
||||
import type { BaseProvider } from '../../../src/providers/base-provider.js';
|
||||
import * as secureFs from '../../../src/lib/secure-fs.js';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { buildPromptWithImages } from '@automaker/utils';
|
||||
|
||||
vi.mock('../../../src/lib/secure-fs.js', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
appendFile: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeatureDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@automaker/utils')>();
|
||||
return {
|
||||
...actual,
|
||||
buildPromptWithImages: vi.fn(),
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('AgentExecutor Summary Extraction', () => {
|
||||
let mockEventBus: TypedEventBus;
|
||||
let mockFeatureStateManager: FeatureStateManager;
|
||||
let mockPlanApprovalService: PlanApprovalService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockEventBus = {
|
||||
emitAutoModeEvent: vi.fn(),
|
||||
} as unknown as TypedEventBus;
|
||||
|
||||
mockFeatureStateManager = {
|
||||
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
|
||||
updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined),
|
||||
saveFeatureSummary: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as FeatureStateManager;
|
||||
|
||||
mockPlanApprovalService = {
|
||||
waitForApproval: vi.fn(),
|
||||
} as unknown as PlanApprovalService;
|
||||
|
||||
(getFeatureDir as Mock).mockReturnValue('/mock/feature/dir');
|
||||
(buildPromptWithImages as Mock).mockResolvedValue({ content: 'mocked prompt' });
|
||||
});
|
||||
|
||||
it('should extract summary from new session content only', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
const previousContent = `Some previous work.
|
||||
<summary>Old summary</summary>`;
|
||||
const newWork = `New implementation work.
|
||||
<summary>New summary</summary>`;
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
previousContent,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify it called saveFeatureSummary with the NEW summary
|
||||
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'New summary'
|
||||
);
|
||||
|
||||
// Ensure it didn't call it with Old summary
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Old summary'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not save summary if no summary in NEW session content', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
const previousContent = `Some previous work.
|
||||
<summary>Old summary</summary>`;
|
||||
const newWork = `New implementation work without a summary tag.`;
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
previousContent,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify it NEVER called saveFeatureSummary because there was no NEW summary
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should extract task summary and update task status during streaming', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Working... ' }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: '[TASK_COMPLETE] T001: Task finished successfully' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
// We trigger executeTasksLoop by providing persistedTasks
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
existingApprovedPlanContent: 'Some plan',
|
||||
persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' as const }],
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify it updated task status with summary
|
||||
expect(mockFeatureStateManager.updateTaskStatus).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'T001',
|
||||
'completed',
|
||||
'Task finished successfully'
|
||||
);
|
||||
});
|
||||
|
||||
describe('Pipeline step summary fallback', () => {
|
||||
it('should save fallback summary when extraction fails for pipeline step', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Content without a summary tag (extraction will fail)
|
||||
const newWork = 'Implementation completed without summary tag.';
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'pipeline_step1' as const, // Pipeline status triggers fallback
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify fallback summary was saved with trimmed content
|
||||
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Implementation completed without summary tag.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not save fallback for non-pipeline status when extraction fails', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Content without a summary tag
|
||||
const newWork = 'Implementation completed without summary tag.';
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'in_progress' as const, // Non-pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify no fallback was saved for non-pipeline status
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not save empty fallback for pipeline step', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Empty/whitespace-only content
|
||||
const newWork = ' \n\t ';
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'pipeline_step1' as const,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify no fallback was saved since content was empty/whitespace
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer extracted summary over fallback for pipeline step', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Content WITH a summary tag
|
||||
const newWork = `Implementation details here.
|
||||
<summary>Proper summary from extraction</summary>`;
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'pipeline_step1' as const,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify extracted summary was saved, not the full content
|
||||
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Proper summary from extraction'
|
||||
);
|
||||
// Ensure it didn't save the full content as fallback
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
expect.stringContaining('Implementation details here')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1181,6 +1181,50 @@ describe('AgentExecutor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass claudeCompatibleProvider to executeQuery options', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' } as any;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(mockProvider.executeQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct result structure', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
@@ -1235,4 +1279,471 @@ describe('AgentExecutor', () => {
|
||||
expect(typeof result.aborted).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline summary fallback with scaffold stripping', () => {
|
||||
it('should strip follow-up scaffold from fallback summary when extraction fails', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Some agent output without summary markers' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1', // Pipeline status to trigger fallback
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// The fallback summary should be called without the scaffold header
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Should not contain the scaffold header
|
||||
expect(savedSummary).not.toContain('---');
|
||||
expect(savedSummary).not.toContain('Follow-up Session');
|
||||
// Should contain the actual content
|
||||
expect(savedSummary).toContain('Some agent output without summary markers');
|
||||
});
|
||||
|
||||
it('should not save fallback when scaffold is the only content after stripping', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
// Provider yields no content - only scaffold will be present
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
// Empty stream - no actual content
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1', // Pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should not save an empty fallback (after scaffold is stripped)
|
||||
expect(saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save extracted summary when available, not fallback', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some content\n\n<summary>Extracted summary here</summary>\n\nMore content',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1', // Pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should save the extracted summary, not the full content
|
||||
expect(saveFeatureSummary).toHaveBeenCalledTimes(1);
|
||||
expect(saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Extracted summary here'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle scaffold with various whitespace patterns', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Agent response here' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should strip scaffold and save actual content
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
expect(savedSummary.trim()).toBe('Agent response here');
|
||||
});
|
||||
|
||||
it('should handle scaffold with extra newlines between markers', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Actual content after scaffold' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Set up with previous content to trigger scaffold insertion
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Verify the scaffold is stripped
|
||||
expect(savedSummary).not.toMatch(/---\s*##\s*Follow-up Session/);
|
||||
});
|
||||
|
||||
it('should handle content without any scaffold (first session)', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'First session output without summary' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// No previousContent means no scaffold
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: undefined, // No previous content
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
expect(savedSummary).toBe('First session output without summary');
|
||||
});
|
||||
|
||||
it('should handle non-pipeline status without saving fallback', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Output without summary' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous content',
|
||||
status: 'implementing', // Non-pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should NOT save fallback for non-pipeline status
|
||||
expect(saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly handle content that starts with dashes but is not scaffold', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
// Content that looks like it might have dashes but is actual content
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: '---This is a code comment or separator---' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: undefined,
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Content should be preserved since it's not the scaffold pattern
|
||||
expect(savedSummary).toContain('---This is a code comment or separator---');
|
||||
});
|
||||
|
||||
it('should handle scaffold at different positions in content', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Content after scaffold marker' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// With previousContent, scaffold will be at the start of sessionContent
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous content',
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Scaffold should be stripped, only actual content remains
|
||||
expect(savedSummary).toBe('Content after scaffold marker');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_idle emission timing (idle check fix)', () => {
|
||||
it('emits auto_mode_idle when no features in any state (empty project)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration and idle event
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle when features are in in_progress status', async () => {
|
||||
// No pending features (backlog/ready)
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// But there are features in in_progress status
|
||||
const inProgressFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'in_progress',
|
||||
title: 'In Progress Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]);
|
||||
// No running features in concurrency manager (they were released during status update)
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle after in_progress feature completes', async () => {
|
||||
const completedFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'completed',
|
||||
title: 'Completed Feature',
|
||||
};
|
||||
|
||||
// Initially has in_progress feature
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should emit auto_mode_idle because all features are completed
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Feature in main worktree has no branchName
|
||||
const mainWorktreeFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-main',
|
||||
status: 'in_progress',
|
||||
title: 'Main Worktree Feature',
|
||||
branchName: undefined, // Main worktree feature
|
||||
};
|
||||
// Feature in branch worktree has branchName
|
||||
const branchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-branch',
|
||||
status: 'in_progress',
|
||||
title: 'Branch Feature',
|
||||
branchName: 'feature/some-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for main worktree
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
|
||||
'auto_mode_idle',
|
||||
expect.objectContaining({
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Feature in matching branch
|
||||
const matchingBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-matching',
|
||||
status: 'in_progress',
|
||||
title: 'Matching Branch Feature',
|
||||
branchName: 'feature/test-branch',
|
||||
};
|
||||
// Feature in different branch
|
||||
const differentBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-different',
|
||||
status: 'in_progress',
|
||||
title: 'Different Branch Feature',
|
||||
branchName: 'feature/other-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([
|
||||
matchingBranchFeature,
|
||||
differentBranchFeature,
|
||||
]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for feature/test-branch
|
||||
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
|
||||
'auto_mode_idle',
|
||||
expect.objectContaining({
|
||||
projectPath: '/test/project',
|
||||
branchName: 'feature/test-branch',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle when in_progress feature has different branchName', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Only feature is in a different branch
|
||||
const differentBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-different',
|
||||
status: 'in_progress',
|
||||
title: 'Different Branch Feature',
|
||||
branchName: 'feature/other-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for feature/test-branch
|
||||
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
|
||||
|
||||
// Should emit auto_mode_idle because the in_progress feature is in a different branch
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: 'feature/test-branch',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => {
|
||||
// backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check
|
||||
// But this test verifies the idle check doesn't incorrectly block on backlog/ready
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check)
|
||||
const backlogFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'backlog',
|
||||
title: 'Backlog Feature',
|
||||
};
|
||||
const readyFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-2',
|
||||
status: 'ready',
|
||||
title: 'Ready Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there are backlog/ready features
|
||||
// (even though they're not in_progress, the idle check only looks at in_progress status)
|
||||
// Actually, backlog/ready would be caught by loadPendingFeatures on next iteration,
|
||||
// so this should emit idle since runningCount=0 and no in_progress features
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features'));
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior)
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => {
|
||||
// Create coordinator without loadAllFeaturesFn
|
||||
const coordWithoutLoadAll = new AutoLoopCoordinator(
|
||||
mockEventBus,
|
||||
mockConcurrencyManager,
|
||||
mockSettingsService,
|
||||
mockExecuteFeature,
|
||||
mockLoadPendingFeatures,
|
||||
mockSaveExecutionState,
|
||||
mockClearExecutionState,
|
||||
mockResetStuckFeatures,
|
||||
mockIsFeatureFinished,
|
||||
mockIsFeatureRunning
|
||||
// loadAllFeaturesFn omitted
|
||||
);
|
||||
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior)
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time multiple times to trigger multiple loop iterations
|
||||
await vi.advanceTimersByTimeAsync(11000); // First idle check
|
||||
await vi.advanceTimersByTimeAsync(11000); // Second idle check
|
||||
await vi.advanceTimersByTimeAsync(11000); // Third idle check
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should only emit auto_mode_idle once despite multiple iterations
|
||||
const idleCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_idle');
|
||||
expect(idleCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => {
|
||||
// This test reproduces the exact bug scenario described in the feature:
|
||||
// When a feature completes, there's a brief window where:
|
||||
// 1. The feature has been released from runningFeatures (so runningCount = 0)
|
||||
// 2. The feature's status is still 'in_progress' during the status update transition
|
||||
// 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses)
|
||||
// The fix ensures auto_mode_idle is NOT emitted in this window
|
||||
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features
|
||||
// Feature is still in in_progress status (during status update transition)
|
||||
const transitioningFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'in_progress',
|
||||
title: 'Transitioning Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]);
|
||||
// Feature has been released from concurrency manager (runningCount = 0)
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// The fix prevents auto_mode_idle from being emitted in this scenario
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
127
apps/server/tests/unit/services/auto-mode-facade.test.ts
Normal file
127
apps/server/tests/unit/services/auto-mode-facade.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutoModeServiceFacade } from '@/services/auto-mode/facade.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('AutoModeServiceFacade', () => {
|
||||
describe('isFeatureEligibleForAutoMode', () => {
|
||||
it('should include features with pipeline_* status', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'ready', branchName: 'main' },
|
||||
{ id: '2', status: 'pipeline_testing', branchName: 'main' },
|
||||
{ id: '3', status: 'in_progress', branchName: 'main' },
|
||||
{ id: '4', status: 'interrupted', branchName: 'main' },
|
||||
{ id: '5', status: 'backlog', branchName: 'main' },
|
||||
];
|
||||
|
||||
const branchName = 'main';
|
||||
const primaryBranch = 'main';
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
|
||||
);
|
||||
|
||||
expect(filtered.map((f) => f.id)).toContain('1'); // ready
|
||||
expect(filtered.map((f) => f.id)).toContain('2'); // pipeline_testing
|
||||
expect(filtered.map((f) => f.id)).toContain('4'); // interrupted
|
||||
expect(filtered.map((f) => f.id)).toContain('5'); // backlog
|
||||
expect(filtered.map((f) => f.id)).not.toContain('3'); // in_progress
|
||||
});
|
||||
|
||||
it('should correctly handle main worktree alignment', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'ready', branchName: undefined },
|
||||
{ id: '2', status: 'ready', branchName: 'main' },
|
||||
{ id: '3', status: 'ready', branchName: 'other' },
|
||||
];
|
||||
|
||||
const branchName = null; // main worktree
|
||||
const primaryBranch = 'main';
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
|
||||
);
|
||||
|
||||
expect(filtered.map((f) => f.id)).toContain('1'); // no branch
|
||||
expect(filtered.map((f) => f.id)).toContain('2'); // matching primary branch
|
||||
expect(filtered.map((f) => f.id)).not.toContain('3'); // mismatching branch
|
||||
});
|
||||
|
||||
it('should exclude completed, verified, and waiting_approval statuses', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'completed', branchName: 'main' },
|
||||
{ id: '2', status: 'verified', branchName: 'main' },
|
||||
{ id: '3', status: 'waiting_approval', branchName: 'main' },
|
||||
];
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'main', 'main')
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include pipeline_complete as eligible (still a pipeline status)', () => {
|
||||
const feature: Partial<Feature> = {
|
||||
id: '1',
|
||||
status: 'pipeline_complete',
|
||||
branchName: 'main',
|
||||
};
|
||||
|
||||
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
|
||||
feature as Feature,
|
||||
'main',
|
||||
'main'
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter pipeline features by branch in named worktrees', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'pipeline_testing', branchName: 'feature-branch' },
|
||||
{ id: '2', status: 'pipeline_review', branchName: 'other-branch' },
|
||||
{ id: '3', status: 'pipeline_deploy', branchName: undefined },
|
||||
];
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'feature-branch', null)
|
||||
);
|
||||
|
||||
expect(filtered.map((f) => f.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('should handle null primaryBranch for main worktree', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'ready', branchName: undefined },
|
||||
{ id: '2', status: 'ready', branchName: 'main' },
|
||||
];
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, null, null)
|
||||
);
|
||||
|
||||
// When primaryBranch is null, only features with no branchName are included
|
||||
expect(filtered.map((f) => f.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('should include various pipeline_* step IDs as eligible', () => {
|
||||
const statuses = [
|
||||
'pipeline_step_abc_123',
|
||||
'pipeline_code_review',
|
||||
'pipeline_step1',
|
||||
'pipeline_testing',
|
||||
'pipeline_deploy',
|
||||
];
|
||||
|
||||
for (const status of statuses) {
|
||||
const feature: Partial<Feature> = { id: '1', status, branchName: 'main' };
|
||||
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
|
||||
feature as Feature,
|
||||
'main',
|
||||
'main'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock dependencies (hoisted)
|
||||
vi.mock('../../../../src/services/agent-executor.js');
|
||||
vi.mock('../../../../src/lib/settings-helpers.js');
|
||||
vi.mock('../../../../src/providers/provider-factory.js');
|
||||
vi.mock('../../../../src/lib/sdk-options.js');
|
||||
vi.mock('@automaker/model-resolver', () => ({
|
||||
resolveModelString: vi.fn((model, fallback) => model || fallback),
|
||||
DEFAULT_MODELS: { claude: 'claude-3-5-sonnet' },
|
||||
}));
|
||||
|
||||
import { AutoModeServiceFacade } from '../../../../src/services/auto-mode/facade.js';
|
||||
import { AgentExecutor } from '../../../../src/services/agent-executor.js';
|
||||
import * as settingsHelpers from '../../../../src/lib/settings-helpers.js';
|
||||
import { ProviderFactory } from '../../../../src/providers/provider-factory.js';
|
||||
import * as sdkOptions from '../../../../src/lib/sdk-options.js';
|
||||
|
||||
describe('AutoModeServiceFacade Agent Runner', () => {
|
||||
let mockAgentExecutor: MockAgentExecutor;
|
||||
let mockSettingsService: MockSettingsService;
|
||||
let facade: AutoModeServiceFacade;
|
||||
|
||||
// Type definitions for mocks
|
||||
interface MockAgentExecutor {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
interface MockSettingsService {
|
||||
getGlobalSettings: ReturnType<typeof vi.fn>;
|
||||
getCredentials: ReturnType<typeof vi.fn>;
|
||||
getProjectSettings: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up the mock for createAutoModeOptions
|
||||
// Note: Using 'as any' because Options type from SDK is complex and we only need
|
||||
// the specific fields that are verified in tests (maxTurns, allowedTools, etc.)
|
||||
vi.mocked(sdkOptions.createAutoModeOptions).mockReturnValue({
|
||||
maxTurns: 123,
|
||||
allowedTools: ['tool1'],
|
||||
systemPrompt: 'system-prompt',
|
||||
} as any);
|
||||
|
||||
mockAgentExecutor = {
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
(AgentExecutor as any).mockImplementation(function (this: MockAgentExecutor) {
|
||||
return mockAgentExecutor;
|
||||
});
|
||||
|
||||
mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
getProjectSettings: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
// Helper to access the private createRunAgentFn via factory creation
|
||||
facade = AutoModeServiceFacade.create('/project', {
|
||||
events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any,
|
||||
settingsService: mockSettingsService,
|
||||
sharedServices: {
|
||||
eventBus: { emitAutoModeEvent: vi.fn() } as any,
|
||||
worktreeResolver: { getCurrentBranch: vi.fn().mockResolvedValue('main') } as any,
|
||||
concurrencyManager: {
|
||||
isRunning: vi.fn().mockReturnValue(false),
|
||||
getRunningFeature: vi.fn().mockReturnValue(null),
|
||||
} as any,
|
||||
} as any,
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve provider by providerId and pass to AgentExecutor', async () => {
|
||||
// 1. Setup mocks
|
||||
const mockProvider = { getName: () => 'mock-provider' };
|
||||
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
|
||||
|
||||
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' };
|
||||
const mockCredentials = { apiKey: 'test-key' };
|
||||
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
|
||||
provider: mockClaudeProvider,
|
||||
credentials: mockCredentials,
|
||||
resolvedModel: undefined,
|
||||
});
|
||||
|
||||
const runAgentFn = (facade as any).executionService.runAgentFn;
|
||||
|
||||
// 2. Execute
|
||||
await runAgentFn(
|
||||
'/workdir',
|
||||
'feature-1',
|
||||
'prompt',
|
||||
new AbortController(),
|
||||
'/project',
|
||||
[],
|
||||
'model-1',
|
||||
{
|
||||
providerId: 'zai-1',
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Verify
|
||||
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
|
||||
mockSettingsService,
|
||||
'model-1',
|
||||
'zai-1',
|
||||
'[AutoModeFacade]'
|
||||
);
|
||||
|
||||
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
credentials: mockCredentials,
|
||||
model: 'model-1', // Original model ID
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to model-based lookup if providerId is not provided', async () => {
|
||||
const mockProvider = { getName: () => 'mock-provider' };
|
||||
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
|
||||
|
||||
const mockClaudeProvider = { id: 'zai-model', name: 'Zai Model' };
|
||||
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
|
||||
provider: mockClaudeProvider,
|
||||
credentials: { apiKey: 'model-key' },
|
||||
resolvedModel: 'resolved-model-1',
|
||||
});
|
||||
|
||||
const runAgentFn = (facade as any).executionService.runAgentFn;
|
||||
|
||||
await runAgentFn(
|
||||
'/workdir',
|
||||
'feature-1',
|
||||
'prompt',
|
||||
new AbortController(),
|
||||
'/project',
|
||||
[],
|
||||
'model-1',
|
||||
{
|
||||
// no providerId
|
||||
}
|
||||
);
|
||||
|
||||
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
|
||||
mockSettingsService,
|
||||
'model-1',
|
||||
undefined,
|
||||
'[AutoModeFacade]'
|
||||
);
|
||||
|
||||
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should use resolvedModel from provider config for createAutoModeOptions if it maps to a Claude model', async () => {
|
||||
const mockProvider = { getName: () => 'mock-provider' };
|
||||
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
|
||||
|
||||
const mockClaudeProvider = {
|
||||
id: 'zai-1',
|
||||
name: 'Zai',
|
||||
models: [{ id: 'custom-model-1', mapsToClaudeModel: 'claude-3-opus' }],
|
||||
};
|
||||
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
|
||||
provider: mockClaudeProvider,
|
||||
credentials: { apiKey: 'test-key' },
|
||||
resolvedModel: 'claude-3-5-opus',
|
||||
});
|
||||
|
||||
const runAgentFn = (facade as any).executionService.runAgentFn;
|
||||
|
||||
await runAgentFn(
|
||||
'/workdir',
|
||||
'feature-1',
|
||||
'prompt',
|
||||
new AbortController(),
|
||||
'/project',
|
||||
[],
|
||||
'custom-model-1',
|
||||
{
|
||||
providerId: 'zai-1',
|
||||
}
|
||||
);
|
||||
|
||||
// Verify createAutoModeOptions was called with the mapped model
|
||||
expect(sdkOptions.createAutoModeOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'claude-3-5-opus',
|
||||
})
|
||||
);
|
||||
|
||||
// Verify AgentExecutor.execute still gets the original custom model ID
|
||||
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'custom-model-1',
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
115
apps/server/tests/unit/services/dev-server-event-types.test.ts
Normal file
115
apps/server/tests/unit/services/dev-server-event-types.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock net
|
||||
vi.mock('net', () => ({
|
||||
default: {
|
||||
createServer: vi.fn(),
|
||||
},
|
||||
createServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import net from 'net';
|
||||
|
||||
describe('DevServerService Event Types', () => {
|
||||
let testDataDir: string;
|
||||
let worktreeDir: string;
|
||||
let mockEmitter: EventEmitter;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
testDataDir = path.join(os.tmpdir(), `dev-server-events-test-${Date.now()}`);
|
||||
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-events-test-${Date.now()}`);
|
||||
await fs.mkdir(testDataDir, { recursive: true });
|
||||
await fs.mkdir(worktreeDir, { recursive: true });
|
||||
|
||||
mockEmitter = new EventEmitter();
|
||||
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
process.nextTick(() => mockServer.emit('listening'));
|
||||
});
|
||||
mockServer.close = vi.fn();
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testDataDir, { recursive: true, force: true });
|
||||
await fs.rm(worktreeDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit all required event types during dev server lifecycle', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const emittedEvents: Record<string, any[]> = {
|
||||
'dev-server:starting': [],
|
||||
'dev-server:started': [],
|
||||
'dev-server:url-detected': [],
|
||||
'dev-server:output': [],
|
||||
'dev-server:stopped': [],
|
||||
};
|
||||
|
||||
Object.keys(emittedEvents).forEach((type) => {
|
||||
mockEmitter.on(type, (payload) => emittedEvents[type].push(payload));
|
||||
});
|
||||
|
||||
// 1. Starting & Started
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
expect(emittedEvents['dev-server:starting'].length).toBe(1);
|
||||
expect(emittedEvents['dev-server:started'].length).toBe(1);
|
||||
|
||||
// 2. Output & URL Detected
|
||||
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
|
||||
// Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1);
|
||||
expect(emittedEvents['dev-server:url-detected'].length).toBe(1);
|
||||
expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/');
|
||||
|
||||
// 3. Stopped
|
||||
await service.stopDevServer(worktreeDir);
|
||||
expect(emittedEvents['dev-server:stopped'].length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock child process
|
||||
function createMockProcess() {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new EventEmitter();
|
||||
mockProcess.stderr = new EventEmitter();
|
||||
mockProcess.kill = vi.fn();
|
||||
mockProcess.killed = false;
|
||||
mockProcess.pid = 12345;
|
||||
mockProcess.unref = vi.fn();
|
||||
return mockProcess;
|
||||
}
|
||||
240
apps/server/tests/unit/services/dev-server-persistence.test.ts
Normal file
240
apps/server/tests/unit/services/dev-server-persistence.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock net
|
||||
vi.mock('net', () => ({
|
||||
default: {
|
||||
createServer: vi.fn(),
|
||||
},
|
||||
createServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import net from 'net';
|
||||
|
||||
describe('DevServerService Persistence & Sync', () => {
|
||||
let testDataDir: string;
|
||||
let worktreeDir: string;
|
||||
let mockEmitter: EventEmitter;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
testDataDir = path.join(os.tmpdir(), `dev-server-persistence-test-${Date.now()}`);
|
||||
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-test-${Date.now()}`);
|
||||
await fs.mkdir(testDataDir, { recursive: true });
|
||||
await fs.mkdir(worktreeDir, { recursive: true });
|
||||
|
||||
mockEmitter = new EventEmitter();
|
||||
|
||||
// Default mock for secureFs.access - return resolved (file exists)
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
// Default mock for net.createServer - port available
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
process.nextTick(() => mockServer.emit('listening'));
|
||||
});
|
||||
mockServer.close = vi.fn();
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
|
||||
// Default mock for execSync - no process on port
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('No process found');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testDataDir, { recursive: true, force: true });
|
||||
await fs.rm(worktreeDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit dev-server:starting when startDevServer is called', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const events: any[] = [];
|
||||
mockEmitter.on('dev-server:starting', (payload) => events.push(payload));
|
||||
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].worktreePath).toBe(worktreeDir);
|
||||
});
|
||||
|
||||
it('should prevent concurrent starts for the same worktree', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
// Delay spawn to simulate long starting time
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
const p = createMockProcess();
|
||||
// Don't return immediately, simulate some work
|
||||
return p as any;
|
||||
});
|
||||
|
||||
// Start first one (don't await yet if we want to test concurrency)
|
||||
const promise1 = service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
// Try to start second one immediately
|
||||
const result2 = await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
expect(result2.success).toBe(false);
|
||||
expect(result2.error).toContain('already starting');
|
||||
|
||||
await promise1;
|
||||
});
|
||||
|
||||
it('should persist state to dev-servers.json when started', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
const statePath = path.join(testDataDir, 'dev-servers.json');
|
||||
const exists = await fs
|
||||
.access(statePath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const content = await fs.readFile(statePath, 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
expect(state.length).toBe(1);
|
||||
expect(state[0].worktreePath).toBe(worktreeDir);
|
||||
});
|
||||
|
||||
it('should load state from dev-servers.json on initialize', async () => {
|
||||
// 1. Create a fake state file
|
||||
const persistedInfo = [
|
||||
{
|
||||
worktreePath: worktreeDir,
|
||||
allocatedPort: 3005,
|
||||
port: 3005,
|
||||
url: 'http://localhost:3005',
|
||||
startedAt: new Date().toISOString(),
|
||||
urlDetected: true,
|
||||
customCommand: 'npm run dev',
|
||||
},
|
||||
];
|
||||
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
|
||||
|
||||
// 2. Mock port as IN USE (so it re-attaches)
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
// Fail to listen = port in use
|
||||
process.nextTick(() => mockServer.emit('error', new Error('EADDRINUSE')));
|
||||
});
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
expect(service.isRunning(worktreeDir)).toBe(true);
|
||||
const info = service.getServerInfo(worktreeDir);
|
||||
expect(info?.port).toBe(3005);
|
||||
});
|
||||
|
||||
it('should prune stale servers from state on initialize if port is available', async () => {
|
||||
// 1. Create a fake state file
|
||||
const persistedInfo = [
|
||||
{
|
||||
worktreePath: worktreeDir,
|
||||
allocatedPort: 3005,
|
||||
port: 3005,
|
||||
url: 'http://localhost:3005',
|
||||
startedAt: new Date().toISOString(),
|
||||
urlDetected: true,
|
||||
},
|
||||
];
|
||||
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
|
||||
|
||||
// 2. Mock port as AVAILABLE (so it prunes)
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
process.nextTick(() => mockServer.emit('listening'));
|
||||
});
|
||||
mockServer.close = vi.fn();
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
expect(service.isRunning(worktreeDir)).toBe(false);
|
||||
|
||||
// Give it a moment to complete the pruning saveState
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Check if file was updated
|
||||
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
expect(state.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should update persisted state when URL is detected', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
// Simulate output with URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n'));
|
||||
|
||||
// Give it a moment to process and save (needs to wait for saveQueue)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
expect(state[0].url).toBe('http://localhost:5555/');
|
||||
expect(state[0].port).toBe(5555);
|
||||
expect(state[0].urlDetected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock child process
|
||||
function createMockProcess() {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new EventEmitter();
|
||||
mockProcess.stderr = new EventEmitter();
|
||||
mockProcess.kill = vi.fn();
|
||||
mockProcess.killed = false;
|
||||
mockProcess.pid = 12345;
|
||||
mockProcess.unref = vi.fn();
|
||||
return mockProcess;
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import type { SettingsService } from '../../../src/services/settings-service.js'
|
||||
import type { EventHistoryService } from '../../../src/services/event-history-service.js';
|
||||
import type { FeatureLoader } from '../../../src/services/feature-loader.js';
|
||||
|
||||
// Mock global fetch for ntfy tests
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
/**
|
||||
* Create a mock EventEmitter for testing
|
||||
*/
|
||||
@@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & {
|
||||
/**
|
||||
* Create a mock SettingsService
|
||||
*/
|
||||
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
|
||||
function createMockSettingsService(
|
||||
hooks: unknown[] = [],
|
||||
ntfyEndpoints: unknown[] = []
|
||||
): SettingsService {
|
||||
return {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
eventHooks: hooks,
|
||||
ntfyEndpoints: ntfyEndpoints,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
}
|
||||
|
||||
@@ -70,6 +79,7 @@ describe('EventHookService', () => {
|
||||
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
|
||||
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
|
||||
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new EventHookService();
|
||||
@@ -77,10 +87,14 @@ describe('EventHookService', () => {
|
||||
mockSettingsService = createMockSettingsService();
|
||||
mockEventHistoryService = createMockEventHistoryService();
|
||||
mockFeatureLoader = createMockFeatureLoader();
|
||||
// Set up mock fetch for ntfy tests
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.destroy();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
@@ -832,4 +846,631 @@ describe('EventHookService', () => {
|
||||
expect(storeCall.error).toBe('Feature stopped by user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ntfy hook execution', () => {
|
||||
const mockNtfyEndpoint = {
|
||||
id: 'endpoint-1',
|
||||
name: 'Test Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none' as const,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it('should execute ntfy hook when endpoint is configured', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Success Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
title: 'Feature {{featureName}} completed!',
|
||||
priority: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers['Title']).toBe('Feature Test Feature completed!');
|
||||
});
|
||||
|
||||
it('should NOT execute ntfy hook when endpoint is not found', async () => {
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Missing Endpoint',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'non-existent-endpoint',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fetch should NOT have been called since endpoint doesn't exist
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use ntfy endpoint default values when hook does not override', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaults = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultTags: 'default-tag',
|
||||
defaultEmoji: 'tada',
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_error',
|
||||
name: 'Ntfy Error Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
// No title, tags, or emoji - should use endpoint defaults
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Failed Feature',
|
||||
passes: false,
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Should use default tags and emoji from endpoint
|
||||
expect(options.headers['Tags']).toBe('tada,default-tag');
|
||||
// Click URL gets deep-link query param when feature context is available
|
||||
expect(options.headers['Click']).toContain('https://default.example.com/board');
|
||||
expect(options.headers['Click']).toContain('featureId=feat-1');
|
||||
});
|
||||
|
||||
it('should send ntfy notification with authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithAuth = {
|
||||
...mockNtfyEndpoint,
|
||||
authType: 'token' as const,
|
||||
token: 'tk_test_token',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Authenticated Ntfy Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Authorization']).toBe('Bearer tk_test_token');
|
||||
});
|
||||
|
||||
it('should handle ntfy notification failure gracefully', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook That Will Fail',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
// Should not throw - error should be caught gracefully
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Event should still be stored even if ntfy hook fails
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should substitute variables in ntfy title and body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Variables',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
title: '[{{projectName}}] {{featureName}}',
|
||||
body: 'Feature {{featureId}} completed at {{timestamp}}',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-123',
|
||||
featureName: 'Cool Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/my-project',
|
||||
projectName: 'my-project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[my-project] Cool Feature');
|
||||
expect(options.body).toContain('feat-123');
|
||||
});
|
||||
|
||||
it('should NOT execute ntfy hook when endpoint is disabled', async () => {
|
||||
const disabledEndpoint = {
|
||||
...mockNtfyEndpoint,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Disabled Endpoint',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fetch should not be called because endpoint is disabled
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use hook-specific values over endpoint defaults', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaults = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultTags: 'default-tag',
|
||||
defaultEmoji: 'default-emoji',
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Overrides',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
tags: 'override-tag',
|
||||
emoji: 'override-emoji',
|
||||
clickUrl: 'https://override.example.com',
|
||||
priority: 5,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Hook values should override endpoint defaults
|
||||
expect(options.headers['Tags']).toBe('override-emoji,override-tag');
|
||||
// Click URL uses hook-specific base URL with deep link params applied
|
||||
expect(options.headers['Click']).toContain('https://override.example.com/board');
|
||||
expect(options.headers['Click']).toContain('featureId=feat-1');
|
||||
expect(options.headers['Priority']).toBe('5');
|
||||
});
|
||||
|
||||
describe('click URL deep linking', () => {
|
||||
it('should generate board URL with featureId query param when feature context is available', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://app.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'test-feature-123',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should use /board path with featureId query param
|
||||
expect(clickUrl).toContain('/board');
|
||||
expect(clickUrl).toContain('featureId=test-feature-123');
|
||||
// Should NOT use the old path-based format
|
||||
expect(clickUrl).not.toContain('/feature/');
|
||||
});
|
||||
|
||||
it('should generate board URL without featureId when no feature context', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://app.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'auto_mode_complete',
|
||||
name: 'Auto Mode Complete Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
// Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete)
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_idle',
|
||||
executionMode: 'auto',
|
||||
projectPath: '/test/project',
|
||||
totalFeatures: 5,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should navigate to board without featureId
|
||||
expect(clickUrl).toContain('/board');
|
||||
expect(clickUrl).not.toContain('featureId=');
|
||||
});
|
||||
|
||||
it('should apply deep link params to hook-specific click URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Custom Click URL',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
clickUrl: 'https://custom.example.com/custom-page',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-789',
|
||||
featureName: 'Custom URL Test',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should use the hook-specific click URL with deep link params applied
|
||||
expect(clickUrl).toContain('https://custom.example.com/board');
|
||||
expect(clickUrl).toContain('featureId=feat-789');
|
||||
});
|
||||
|
||||
it('should preserve existing query params when adding featureId', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://app.example.com/board?view=list',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-456',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should preserve existing query params and add featureId
|
||||
expect(clickUrl).toContain('view=list');
|
||||
expect(clickUrl).toContain('featureId=feat-456');
|
||||
// Should be properly formatted URL
|
||||
expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -451,13 +451,28 @@ describe('execution-service.ts', () => {
|
||||
const callArgs = mockRunAgentFn.mock.calls[0];
|
||||
expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project
|
||||
expect(callArgs[1]).toBe('feature-1');
|
||||
expect(callArgs[2]).toContain('Feature Implementation Task');
|
||||
expect(callArgs[2]).toContain('Feature Task');
|
||||
expect(callArgs[3]).toBeInstanceOf(AbortController);
|
||||
expect(callArgs[4]).toBe('/test/project');
|
||||
// Model (index 6) should be resolved
|
||||
expect(callArgs[6]).toBe('claude-sonnet-4');
|
||||
});
|
||||
|
||||
it('passes providerId to runAgentFn when present on feature', async () => {
|
||||
const featureWithProvider: Feature = {
|
||||
...testFeature,
|
||||
providerId: 'zai-provider-1',
|
||||
};
|
||||
vi.mocked(mockLoadFeatureFn).mockResolvedValue(featureWithProvider);
|
||||
|
||||
await service.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockRunAgentFn).toHaveBeenCalled();
|
||||
const callArgs = mockRunAgentFn.mock.calls[0];
|
||||
const options = callArgs[7];
|
||||
expect(options.providerId).toBe('zai-provider-1');
|
||||
});
|
||||
|
||||
it('executes pipeline after agent completes', async () => {
|
||||
const pipelineSteps = [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }];
|
||||
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
|
||||
@@ -1316,16 +1331,19 @@ describe('execution-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to project path when worktree not found', async () => {
|
||||
it('emits error and does not execute agent when worktree is not found in worktree mode', async () => {
|
||||
vi.mocked(mockWorktreeResolver.findWorktreeForBranch).mockResolvedValue(null);
|
||||
|
||||
await service.executeFeature('/test/project', 'feature-1', true);
|
||||
|
||||
// Should still run agent, just with project path
|
||||
expect(mockRunAgentFn).toHaveBeenCalled();
|
||||
const callArgs = mockRunAgentFn.mock.calls[0];
|
||||
// First argument is workDir - should be normalized path to /test/project
|
||||
expect(callArgs[0]).toBe(normalizePath('/test/project'));
|
||||
expect(mockRunAgentFn).not.toHaveBeenCalled();
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||
'auto_mode_error',
|
||||
expect.objectContaining({
|
||||
featureId: 'feature-1',
|
||||
error: 'Worktree enabled but no worktree found for feature branch "feature/test-1".',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips worktree resolution when useWorktrees is false', async () => {
|
||||
@@ -1439,6 +1457,114 @@ describe('execution-service.ts', () => {
|
||||
expect.objectContaining({ passes: true })
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to create ExecutionService with a custom loadFeatureFn that returns
|
||||
// different features on first load (initial) vs subsequent loads (after completion)
|
||||
const createServiceWithCustomLoad = (completedFeature: Feature): ExecutionService => {
|
||||
let loadCallCount = 0;
|
||||
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
|
||||
loadCallCount++;
|
||||
return loadCallCount === 1 ? testFeature : completedFeature;
|
||||
});
|
||||
|
||||
return new ExecutionService(
|
||||
mockEventBus,
|
||||
mockConcurrencyManager,
|
||||
mockWorktreeResolver,
|
||||
mockSettingsService,
|
||||
mockRunAgentFn,
|
||||
mockExecutePipelineFn,
|
||||
mockUpdateFeatureStatusFn,
|
||||
mockLoadFeatureFn,
|
||||
mockGetPlanningPromptPrefixFn,
|
||||
mockSaveFeatureSummaryFn,
|
||||
mockRecordLearningsFn,
|
||||
mockContextExistsFn,
|
||||
mockResumeFeatureFn,
|
||||
mockTrackFailureFn,
|
||||
mockSignalPauseFn,
|
||||
mockRecordSuccessFn,
|
||||
mockSaveExecutionStateFn,
|
||||
mockLoadContextFilesFn
|
||||
);
|
||||
};
|
||||
|
||||
it('does not overwrite accumulated summary when feature already has one', async () => {
|
||||
const featureWithAccumulatedSummary: Feature = {
|
||||
...testFeature,
|
||||
summary:
|
||||
'### Implementation\n\nFirst step output\n\n---\n\n### Code Review\n\nReview findings',
|
||||
};
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// saveFeatureSummaryFn should NOT be called because feature already has a summary
|
||||
// This prevents overwriting accumulated pipeline summaries with just the last step's output
|
||||
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves summary when feature has no existing summary', async () => {
|
||||
const featureWithoutSummary: Feature = {
|
||||
...testFeature,
|
||||
summary: undefined,
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(
|
||||
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
|
||||
);
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithoutSummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should save the extracted summary since feature has none
|
||||
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'Test summary'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not overwrite summary when feature has empty string summary (treats as no summary)', async () => {
|
||||
// Empty string is falsy, so it should be treated as "no summary" and a new one should be saved
|
||||
const featureWithEmptySummary: Feature = {
|
||||
...testFeature,
|
||||
summary: '',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(
|
||||
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
|
||||
);
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithEmptySummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Empty string is falsy, so it should save a new summary
|
||||
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'Test summary'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves accumulated summary when feature is transitioned from pipeline to verified', async () => {
|
||||
// This is the key scenario: feature went through pipeline steps, accumulated a summary,
|
||||
// then status changed to 'verified' - we should NOT overwrite the accumulated summary
|
||||
const featureWithAccumulatedSummary: Feature = {
|
||||
...testFeature,
|
||||
status: 'verified',
|
||||
summary:
|
||||
'### Implementation\n\nCreated auth module\n\n---\n\n### Code Review\n\nApproved\n\n---\n\n### Testing\n\nAll tests pass',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary');
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// The accumulated summary should be preserved
|
||||
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeFeature - agent output validation', () => {
|
||||
@@ -1874,5 +2000,60 @@ describe('execution-service.ts', () => {
|
||||
// The only non-in_progress status call should be absent since merge_conflict returns early
|
||||
expect(statusCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('sets waiting_approval instead of backlog when error occurs after pipeline completes', async () => {
|
||||
// Set up pipeline with steps
|
||||
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
|
||||
version: 1,
|
||||
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
|
||||
});
|
||||
|
||||
// Pipeline succeeds, but reading agent output throws after pipeline completes
|
||||
mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined);
|
||||
// Simulate an error after pipeline completes by making loadFeature throw
|
||||
// on the post-pipeline refresh call
|
||||
let loadCallCount = 0;
|
||||
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
|
||||
loadCallCount++;
|
||||
if (loadCallCount === 1) return testFeature; // initial load
|
||||
// Second call is the task-retry check, third is the pipeline refresh
|
||||
if (loadCallCount <= 2) return testFeature;
|
||||
throw new Error('Unexpected post-pipeline error');
|
||||
});
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should set to waiting_approval, NOT backlog, since pipeline completed
|
||||
const backlogCalls = vi
|
||||
.mocked(mockUpdateFeatureStatusFn)
|
||||
.mock.calls.filter((call) => call[2] === 'backlog');
|
||||
expect(backlogCalls.length).toBe(0);
|
||||
|
||||
const waitingCalls = vi
|
||||
.mocked(mockUpdateFeatureStatusFn)
|
||||
.mock.calls.filter((call) => call[2] === 'waiting_approval');
|
||||
expect(waitingCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still sets backlog when error occurs before pipeline completes', async () => {
|
||||
// Set up pipeline with steps
|
||||
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
|
||||
version: 1,
|
||||
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
|
||||
});
|
||||
|
||||
// Pipeline itself throws (e.g., agent error during pipeline step)
|
||||
mockExecutePipelineFn = vi.fn().mockRejectedValue(new Error('Agent execution failed'));
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should still set to backlog since pipeline did NOT complete
|
||||
const backlogCalls = vi
|
||||
.mocked(mockUpdateFeatureStatusFn)
|
||||
.mock.calls.filter((call) => call[2] === 'backlog');
|
||||
expect(backlogCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,17 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import path from 'path';
|
||||
import { FeatureStateManager } from '@/services/feature-state-manager.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
|
||||
const PIPELINE_SUMMARY_SEPARATOR = '\n\n---\n\n';
|
||||
const PIPELINE_SUMMARY_HEADER_PREFIX = '### ';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
|
||||
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
|
||||
import { getNotificationService } from '@/services/notification-service.js';
|
||||
import { pipelineService } from '@/services/pipeline-service.js';
|
||||
|
||||
/**
|
||||
* Helper to normalize paths for cross-platform test compatibility.
|
||||
@@ -42,6 +47,16 @@ vi.mock('@/services/notification-service.js', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/pipeline-service.js', () => ({
|
||||
pipelineService: {
|
||||
getStepIdFromStatus: vi.fn((status: string) => {
|
||||
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
|
||||
return null;
|
||||
}),
|
||||
getStep: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FeatureStateManager', () => {
|
||||
let manager: FeatureStateManager;
|
||||
let mockEvents: EventEmitter;
|
||||
@@ -264,6 +279,81 @@ describe('FeatureStateManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature.title as notification title for waiting_approval status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: 'My Awesome Feature Title',
|
||||
name: 'old-name-property', // name property exists but should not be used
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'My Awesome Feature Title',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithoutTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: undefined,
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithoutTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithEmptyTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: '',
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithEmptyTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create notification for verified status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
@@ -283,6 +373,81 @@ describe('FeatureStateManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature.title as notification title for verified status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: 'My Awesome Feature Title',
|
||||
name: 'old-name-property', // name property exists but should not be used
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'My Awesome Feature Title',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithoutTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: undefined,
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithoutTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string title by using featureId as notification title in verified notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithEmptyTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: '',
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithEmptyTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should sync to app_spec for completed status', async () => {
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature },
|
||||
@@ -341,9 +506,6 @@ describe('FeatureStateManager', () => {
|
||||
|
||||
describe('markFeatureInterrupted', () => {
|
||||
it('should mark feature as interrupted', async () => {
|
||||
(secureFs.readFile as Mock).mockResolvedValue(
|
||||
JSON.stringify({ ...mockFeature, status: 'in_progress' })
|
||||
);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'in_progress' },
|
||||
recovered: false,
|
||||
@@ -358,20 +520,25 @@ describe('FeatureStateManager', () => {
|
||||
});
|
||||
|
||||
it('should preserve pipeline_* statuses', async () => {
|
||||
(secureFs.readFile as Mock).mockResolvedValue(
|
||||
JSON.stringify({ ...mockFeature, status: 'pipeline_step_1' })
|
||||
);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step_1' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown');
|
||||
|
||||
// Should NOT call atomicWriteJson because pipeline status is preserved
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
expect(isPipelineStatus('pipeline_step_1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve pipeline_complete status', async () => {
|
||||
(secureFs.readFile as Mock).mockResolvedValue(
|
||||
JSON.stringify({ ...mockFeature, status: 'pipeline_complete' })
|
||||
);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_complete' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.markFeatureInterrupted('/project', 'feature-123');
|
||||
|
||||
@@ -379,7 +546,6 @@ describe('FeatureStateManager', () => {
|
||||
});
|
||||
|
||||
it('should handle feature not found', async () => {
|
||||
(secureFs.readFile as Mock).mockRejectedValue(new Error('ENOENT'));
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: null,
|
||||
recovered: true,
|
||||
@@ -439,6 +605,29 @@ describe('FeatureStateManager', () => {
|
||||
expect(savedFeature.status).toBe('backlog');
|
||||
});
|
||||
|
||||
it('should preserve pipeline_* statuses during reset', async () => {
|
||||
const pipelineFeature: Feature = {
|
||||
...mockFeature,
|
||||
status: 'pipeline_testing',
|
||||
planSpec: { status: 'approved', version: 1, reviewedByUser: true },
|
||||
};
|
||||
|
||||
(secureFs.readdir as Mock).mockResolvedValue([
|
||||
{ name: 'feature-123', isDirectory: () => true },
|
||||
]);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: pipelineFeature,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.resetStuckFeatures('/project');
|
||||
|
||||
// Status should NOT be changed, but needsUpdate might be true if other things reset
|
||||
// In this case, nothing else should be reset, so atomicWriteJson shouldn't be called
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset generating planSpec status to pending', async () => {
|
||||
const stuckFeature: Feature = {
|
||||
...mockFeature,
|
||||
@@ -628,6 +817,379 @@ describe('FeatureStateManager', () => {
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
expect(mockEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accumulate summary with step header for pipeline features', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'First step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should append subsequent pipeline step summaries with separator', async () => {
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nSecond step output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize existing non-phase summary before appending pipeline step summary', async () => {
|
||||
const existingSummary = 'Implemented authentication and settings management.';
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Reviewed and approved changes');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nImplemented authentication and settings management.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReviewed and approved changes`
|
||||
);
|
||||
});
|
||||
|
||||
it('should use fallback step name when pipeline step not found', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue(null);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_unknown_step', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Unknown Step\n\nStep output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should overwrite summary for non-pipeline features', async () => {
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'in_progress', summary: 'Old summary' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'New summary');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe('New summary');
|
||||
});
|
||||
|
||||
it('should emit full accumulated summary for pipeline features', async () => {
|
||||
const existingSummary = '### Code Review\n\nFirst step output';
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Refinement output');
|
||||
|
||||
const expectedSummary =
|
||||
'### Code Review\n\nFirst step output\n\n---\n\n### Refinement\n\nRefinement output';
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/project',
|
||||
summary: expectedSummary,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip accumulation for pipeline features when summary is empty', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: '' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Test output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Empty string is falsy, so should start fresh
|
||||
expect(savedFeature.summary).toBe('### Testing\n\nTest output');
|
||||
});
|
||||
|
||||
it('should skip persistence when incoming summary is only whitespace', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: '### Existing\n\nValue' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', ' \n\t ');
|
||||
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
expect(mockEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accumulate three pipeline steps in chronological order', async () => {
|
||||
// Step 1: Code Review
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Review findings');
|
||||
const afterStep1 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(afterStep1.summary).toBe('### Code Review\n\nReview findings');
|
||||
|
||||
// Step 2: Testing (summary from step 1 exists)
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: afterStep1.summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
|
||||
const afterStep2 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
|
||||
// Step 3: Refinement (summaries from steps 1+2 exist)
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step3' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step3', summary: afterStep2.summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Code polished');
|
||||
const afterStep3 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
|
||||
// Verify the full accumulated summary has all three steps in order
|
||||
expect(afterStep3.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReview findings${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Refinement\n\nCode polished`
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace existing step summary if called again for the same step', async () => {
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst review attempt`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'feature-123',
|
||||
'Second review attempt (success)'
|
||||
);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Should REPLACE "First review attempt" with "Second review attempt (success)"
|
||||
// and NOT append it as a new section
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nSecond review attempt (success)`
|
||||
);
|
||||
// Ensure it didn't duplicate the separator or header
|
||||
expect(
|
||||
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_HEADER_PREFIX + 'Code Review', 'g'))
|
||||
?.length
|
||||
).toBe(1);
|
||||
expect(
|
||||
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_SEPARATOR.trim(), 'g'))?.length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should replace last step summary without trailing separator', async () => {
|
||||
// Test case: replacing the last step which has no separator after it
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nFirst test run`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace first step summary with separator after it', async () => {
|
||||
// Test case: replacing the first step which has a separator after it
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nFirst attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nSecond attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
|
||||
);
|
||||
});
|
||||
|
||||
it('should not match step header appearing in body text, only at section boundaries', async () => {
|
||||
// Test case: body text contains "### Testing" which should NOT be matched
|
||||
// Only headers at actual section boundaries should be replaced
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nReal test section`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Updated test results');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// The section replacement should only replace the actual Testing section at the boundary
|
||||
// NOT the "### Testing" that appears in the body text
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nUpdated test results`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle step name with special regex characters safely', async () => {
|
||||
// Test case: step name contains characters that would break regex
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nFirst attempt`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code (Review)', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nSecond attempt`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle step name with brackets safely', async () => {
|
||||
// Test case: step name contains array-like syntax [0]
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nFirst attempt`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step [0]', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nSecond attempt`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pipelineService.getStepIdFromStatus throwing an error gracefully', async () => {
|
||||
(pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => {
|
||||
throw new Error('Config not found');
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_my_step', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Should use fallback: capitalize each word in the status suffix
|
||||
expect(savedFeature.summary).toBe(`${PIPELINE_SUMMARY_HEADER_PREFIX}My Step\n\nStep output`);
|
||||
});
|
||||
|
||||
it('should handle pipelineService.getStep throwing an error gracefully', async () => {
|
||||
(pipelineService.getStep as Mock).mockRejectedValue(new Error('Disk read error'));
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_code_review', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Should use fallback: capitalize each word in the status suffix
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nStep output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle summary content with markdown formatting', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const markdownSummary =
|
||||
'## Changes Made\n- Fixed **bug** in `parser.ts`\n- Added `validateInput()` function\n\n```typescript\nconst x = 1;\n```';
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', markdownSummary);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\n${markdownSummary}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist before emitting event for pipeline summary accumulation', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const existingSummary = '### Code Review\n\nFirst step output';
|
||||
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
(atomicWriteJson as Mock).mockImplementation(async () => {
|
||||
callOrder.push('persist');
|
||||
});
|
||||
(mockEvents.emit as Mock).mockImplementation(() => {
|
||||
callOrder.push('emit');
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Test results');
|
||||
|
||||
expect(callOrder).toEqual(['persist', 'emit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskStatus', () => {
|
||||
@@ -668,6 +1230,48 @@ describe('FeatureStateManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should update task status and summary and emit event', async () => {
|
||||
const featureWithTasks: Feature = {
|
||||
...mockFeature,
|
||||
planSpec: {
|
||||
status: 'approved',
|
||||
version: 1,
|
||||
reviewedByUser: true,
|
||||
tasks: [{ id: 'task-1', title: 'Task 1', status: 'pending', description: '' }],
|
||||
},
|
||||
};
|
||||
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTasks,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateTaskStatus(
|
||||
'/project',
|
||||
'feature-123',
|
||||
'task-1',
|
||||
'completed',
|
||||
'Task finished successfully'
|
||||
);
|
||||
|
||||
// Verify persisted
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
|
||||
expect(savedFeature.planSpec?.tasks?.[0].summary).toBe('Task finished successfully');
|
||||
|
||||
// Verify event emitted
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_task_status',
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/project',
|
||||
taskId: 'task-1',
|
||||
status: 'completed',
|
||||
summary: 'Task finished successfully',
|
||||
tasks: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle task not found', async () => {
|
||||
const featureWithTasks: Feature = {
|
||||
...mockFeature,
|
||||
@@ -757,4 +1361,179 @@ describe('FeatureStateManager', () => {
|
||||
expect(callOrder).toEqual(['persist', 'emit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAutoModeEventError', () => {
|
||||
let subscribeCallback: (type: string, payload: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0]
|
||||
// subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0]
|
||||
const mockCalls = (mockEvents.subscribe as Mock).mock.calls;
|
||||
if (mockCalls.length > 0 && mockCalls[0].length > 0) {
|
||||
subscribeCallback = mockCalls[0][0] as typeof subscribeCallback;
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore events with no type', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore non-error events', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create auto_mode_error notification with gesture name as title when no featureId', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_error',
|
||||
title: 'Auto Mode Error',
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/project',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use error field instead of message when available', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Some message',
|
||||
error: 'The actual error',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_error',
|
||||
message: 'The actual error',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature title as notification title for feature error with featureId', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, title: 'Login Page Feature' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: false,
|
||||
featureId: 'feature-123',
|
||||
error: 'Build failed',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
// Wait for async handleAutoModeEventError to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_error',
|
||||
title: 'Login Page Feature',
|
||||
message: 'Feature Failed: Build failed',
|
||||
featureId: 'feature-123',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore auto_mode_feature_complete without passes=false', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing projectPath gracefully', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Error occurred',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle notification service failures gracefully', async () => {
|
||||
(getNotificationService as Mock).mockImplementation(() => {
|
||||
throw new Error('Service unavailable');
|
||||
});
|
||||
|
||||
// Should not throw - the callback returns void so we just call it and wait for async work
|
||||
subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Error',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
// Give async handleAutoModeEventError time to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should unsubscribe from event subscription', () => {
|
||||
const unsubscribeFn = vi.fn();
|
||||
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
|
||||
|
||||
// Create a new manager to get a fresh subscription
|
||||
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
// Call destroy
|
||||
newManager.destroy();
|
||||
|
||||
// Verify unsubscribe was called
|
||||
expect(unsubscribeFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle destroy being called multiple times', () => {
|
||||
const unsubscribeFn = vi.fn();
|
||||
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
|
||||
|
||||
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
// Call destroy multiple times
|
||||
newManager.destroy();
|
||||
newManager.destroy();
|
||||
|
||||
// Should only unsubscribe once
|
||||
expect(unsubscribeFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal file
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NtfyService } from '../../../src/services/ntfy-service.js';
|
||||
import type { NtfyEndpointConfig } from '@automaker/types';
|
||||
|
||||
// Mock global fetch
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe('NtfyService', () => {
|
||||
let service: NtfyService;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new NtfyService();
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a valid endpoint config for testing
|
||||
*/
|
||||
function createEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
|
||||
return {
|
||||
id: 'test-endpoint-id',
|
||||
name: 'Test Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic context for testing
|
||||
*/
|
||||
function createContext() {
|
||||
return {
|
||||
featureId: 'feat-123',
|
||||
featureName: 'Test Feature',
|
||||
projectPath: '/test/project',
|
||||
projectName: 'test-project',
|
||||
timestamp: '2024-01-15T10:30:00.000Z',
|
||||
eventType: 'feature_success',
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateEndpoint', () => {
|
||||
it('should return null for valid endpoint with no auth', () => {
|
||||
const endpoint = createEndpoint();
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid endpoint with basic auth', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid endpoint with token auth', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: 'tk_123456',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error when serverUrl is missing', () => {
|
||||
const endpoint = createEndpoint({ serverUrl: '' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Server URL is required');
|
||||
});
|
||||
|
||||
it('should return error when serverUrl is invalid', () => {
|
||||
const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Invalid server URL format');
|
||||
});
|
||||
|
||||
it('should return error when topic is missing', () => {
|
||||
const endpoint = createEndpoint({ topic: '' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic is required');
|
||||
});
|
||||
|
||||
it('should return error when topic contains spaces', () => {
|
||||
const endpoint = createEndpoint({ topic: 'invalid topic' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic cannot contain spaces');
|
||||
});
|
||||
|
||||
it('should return error when topic contains tabs', () => {
|
||||
const endpoint = createEndpoint({ topic: 'invalid\ttopic' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic cannot contain spaces');
|
||||
});
|
||||
|
||||
it('should return error when basic auth is missing username', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: '',
|
||||
password: 'pass',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Username and password are required for basic authentication');
|
||||
});
|
||||
|
||||
it('should return error when basic auth is missing password', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: '',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Username and password are required for basic authentication');
|
||||
});
|
||||
|
||||
it('should return error when token auth is missing token', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: '',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Access token is required for token authentication');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendNotification', () => {
|
||||
it('should return error when endpoint is disabled', async () => {
|
||||
const endpoint = createEndpoint({ enabled: false });
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Endpoint is disabled');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when endpoint validation fails', async () => {
|
||||
const endpoint = createEndpoint({ serverUrl: '' });
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Server URL is required');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send notification with default values', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8');
|
||||
expect(options.headers['Title']).toContain('Feature Completed');
|
||||
expect(options.headers['Priority']).toBe('3');
|
||||
});
|
||||
|
||||
it('should send notification with custom title and body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: 'Custom Title',
|
||||
body: 'Custom body message',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Custom Title');
|
||||
expect(options.body).toBe('Custom body message');
|
||||
});
|
||||
|
||||
it('should send notification with tags and emoji', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
tags: 'warning,skull',
|
||||
emoji: 'tada',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('tada,warning,skull');
|
||||
});
|
||||
|
||||
it('should send notification with priority', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, { priority: 5 }, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Priority']).toBe('5');
|
||||
});
|
||||
|
||||
it('should send notification with click URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{ clickUrl: 'https://example.com/feature/123' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Click']).toBe('https://example.com/feature/123');
|
||||
});
|
||||
|
||||
it('should use endpoint default tags and emoji when not specified', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
defaultTags: 'default-tag',
|
||||
defaultEmoji: 'rocket',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('rocket,default-tag');
|
||||
});
|
||||
|
||||
it('should use endpoint default click URL when not specified', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Click']).toBe('https://default.example.com');
|
||||
});
|
||||
|
||||
it('should send notification with basic authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Basic auth should be base64 encoded
|
||||
const expectedAuth = Buffer.from('testuser:testpass').toString('base64');
|
||||
expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`);
|
||||
});
|
||||
|
||||
it('should send notification with token authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: 'tk_test_token_123',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123');
|
||||
});
|
||||
|
||||
it('should return error on HTTP error response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve('Forbidden - invalid token'),
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('403');
|
||||
expect(result.error).toContain('Forbidden');
|
||||
});
|
||||
|
||||
it('should return error on timeout', async () => {
|
||||
mockFetch.mockImplementationOnce(() => {
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Request timed out');
|
||||
});
|
||||
|
||||
it('should return error on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Network error');
|
||||
});
|
||||
|
||||
it('should handle server URL with trailing slash', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' });
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const url = mockFetch.mock.calls[0][0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
});
|
||||
|
||||
it('should URL encode the topic', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({ topic: 'test/topic#special' });
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const url = mockFetch.mock.calls[0][0];
|
||||
expect(url).toContain('test%2Ftopic%23special');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable substitution', () => {
|
||||
it('should substitute {{featureId}} in title', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: 'Feature {{featureId}} completed' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature feat-123 completed');
|
||||
});
|
||||
|
||||
it('should substitute {{featureName}} in body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ body: 'The feature "{{featureName}}" is done!' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toBe('The feature "Test Feature" is done!');
|
||||
});
|
||||
|
||||
it('should substitute {{projectName}} in title', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: '[{{projectName}}] Event: {{eventType}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[test-project] Event: feature_success');
|
||||
});
|
||||
|
||||
it('should substitute {{timestamp}} in body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ body: 'Completed at: {{timestamp}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should substitute {{error}} in body for error events', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'feature_error',
|
||||
error: 'Something went wrong',
|
||||
};
|
||||
await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Error: Something went wrong');
|
||||
});
|
||||
|
||||
it('should substitute multiple variables', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: '[{{projectName}}] {{featureName}}',
|
||||
body: 'Feature {{featureId}} completed at {{timestamp}}',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[test-project] Test Feature');
|
||||
expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should replace unknown variables with empty string', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: 'Value: {{unknownVariable}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Value: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default title generation', () => {
|
||||
it('should generate title with feature name for feature_success', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Completed: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate title without feature name when missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), featureName: undefined };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Completed');
|
||||
});
|
||||
|
||||
it('should generate correct title for feature_created', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'feature_created' };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Created: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate correct title for feature_error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'feature_error' };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Failed: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate correct title for auto_mode_complete', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'auto_mode_complete',
|
||||
featureName: undefined,
|
||||
};
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Auto Mode Complete');
|
||||
});
|
||||
|
||||
it('should generate correct title for auto_mode_error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Auto Mode Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default body generation', () => {
|
||||
it('should generate body with feature info', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toContain('Feature: Test Feature');
|
||||
expect(options.body).toContain('ID: feat-123');
|
||||
expect(options.body).toContain('Project: test-project');
|
||||
expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should include error in body for error events', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'feature_error',
|
||||
error: 'Build failed',
|
||||
};
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toContain('Error: Build failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emoji and tags handling', () => {
|
||||
it('should handle emoji shortcode with colons', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('tada');
|
||||
});
|
||||
|
||||
it('should handle emoji without colons', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, { emoji: 'warning' }, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('warning');
|
||||
});
|
||||
|
||||
it('should combine emoji and tags correctly', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ emoji: 'rotating_light', tags: 'urgent,alert' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Emoji comes first, then tags
|
||||
expect(options.headers['Tags']).toBe('rotating_light,urgent,alert');
|
||||
});
|
||||
|
||||
it('should ignore emoji with spaces', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ emoji: 'multi word emoji', tags: 'test' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PipelineOrchestrator } from '../../../src/services/pipeline-orchestrator.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('PipelineOrchestrator Prompts', () => {
|
||||
const mockFeature: Feature = {
|
||||
id: 'feature-123',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
status: 'in_progress',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
const mockBuildFeaturePrompt = (feature: Feature) => `Feature: ${feature.title}`;
|
||||
|
||||
it('should include mandatory summary requirement in pipeline step prompt', () => {
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
null as any, // eventBus
|
||||
null as any, // featureStateManager
|
||||
null as any, // agentExecutor
|
||||
null as any, // testRunnerService
|
||||
null as any, // worktreeResolver
|
||||
null as any, // concurrencyManager
|
||||
null as any, // settingsService
|
||||
null as any, // updateFeatureStatusFn
|
||||
null as any, // loadContextFilesFn
|
||||
mockBuildFeaturePrompt,
|
||||
null as any, // executeFeatureFn
|
||||
null as any // runAgentFn
|
||||
);
|
||||
|
||||
const step = {
|
||||
id: 'step1',
|
||||
name: 'Code Review',
|
||||
instructions: 'Review the code for quality.',
|
||||
};
|
||||
|
||||
const prompt = orchestrator.buildPipelineStepPrompt(
|
||||
step as any,
|
||||
mockFeature,
|
||||
'Previous work context',
|
||||
{ implementationInstructions: '', playwrightVerificationInstructions: '' }
|
||||
);
|
||||
|
||||
expect(prompt).toContain('## Pipeline Step: Code Review');
|
||||
expect(prompt).toContain('Review the code for quality.');
|
||||
expect(prompt).toContain(
|
||||
'**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**'
|
||||
);
|
||||
expect(prompt).toContain('<summary>');
|
||||
expect(prompt).toContain('## Summary: Code Review');
|
||||
expect(prompt).toContain('</summary>');
|
||||
expect(prompt).toContain('The <summary> and </summary> tags MUST be on their own lines.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Tests for providerId passthrough in PipelineOrchestrator
|
||||
* Verifies that feature.providerId is forwarded to runAgentFn in both
|
||||
* executePipeline (step execution) and executeTestStep (test fix) contexts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Feature, PipelineStep } from '@automaker/types';
|
||||
import {
|
||||
PipelineOrchestrator,
|
||||
type PipelineContext,
|
||||
type UpdateFeatureStatusFn,
|
||||
type BuildFeaturePromptFn,
|
||||
type ExecuteFeatureFn,
|
||||
type RunAgentFn,
|
||||
} from '../../../src/services/pipeline-orchestrator.js';
|
||||
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
|
||||
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
|
||||
import type { AgentExecutor } from '../../../src/services/agent-executor.js';
|
||||
import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js';
|
||||
import type { SettingsService } from '../../../src/services/settings-service.js';
|
||||
import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js';
|
||||
import type { TestRunnerService } from '../../../src/services/test-runner-service.js';
|
||||
import * as secureFs from '../../../src/lib/secure-fs.js';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../../../src/lib/settings-helpers.js';
|
||||
|
||||
// Mock pipelineService
|
||||
vi.mock('../../../src/services/pipeline-service.js', () => ({
|
||||
pipelineService: {
|
||||
isPipelineStatus: vi.fn(),
|
||||
getStepIdFromStatus: vi.fn(),
|
||||
getPipelineConfig: vi.fn(),
|
||||
getNextStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock merge-service
|
||||
vi.mock('../../../src/services/merge-service.js', () => ({
|
||||
performMerge: vi.fn().mockResolvedValue({ success: true }),
|
||||
}));
|
||||
|
||||
// Mock secureFs
|
||||
vi.mock('../../../src/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock settings helpers
|
||||
vi.mock('../../../src/lib/settings-helpers.js', () => ({
|
||||
getPromptCustomization: vi.fn().mockResolvedValue({
|
||||
taskExecution: {
|
||||
implementationInstructions: 'test instructions',
|
||||
playwrightVerificationInstructions: 'test playwright',
|
||||
},
|
||||
}),
|
||||
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
|
||||
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
|
||||
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
|
||||
}));
|
||||
|
||||
// Mock validateWorkingDirectory
|
||||
vi.mock('../../../src/lib/sdk-options.js', () => ({
|
||||
validateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock platform
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeatureDir: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock model-resolver
|
||||
vi.mock('@automaker/model-resolver', () => ({
|
||||
resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'),
|
||||
DEFAULT_MODELS: { claude: 'claude-sonnet-4' },
|
||||
}));
|
||||
|
||||
describe('PipelineOrchestrator - providerId passthrough', () => {
|
||||
let mockEventBus: TypedEventBus;
|
||||
let mockFeatureStateManager: FeatureStateManager;
|
||||
let mockAgentExecutor: AgentExecutor;
|
||||
let mockTestRunnerService: TestRunnerService;
|
||||
let mockWorktreeResolver: WorktreeResolver;
|
||||
let mockConcurrencyManager: ConcurrencyManager;
|
||||
let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn;
|
||||
let mockLoadContextFilesFn: vi.Mock;
|
||||
let mockBuildFeaturePromptFn: BuildFeaturePromptFn;
|
||||
let mockExecuteFeatureFn: ExecuteFeatureFn;
|
||||
let mockRunAgentFn: RunAgentFn;
|
||||
let orchestrator: PipelineOrchestrator;
|
||||
|
||||
const testSteps: PipelineStep[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: 'Step 1',
|
||||
order: 1,
|
||||
instructions: 'Do step 1',
|
||||
colorClass: 'blue',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
const createFeatureWithProvider = (providerId?: string): Feature => ({
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
category: 'test',
|
||||
description: 'Test description',
|
||||
status: 'pipeline_step-1',
|
||||
branchName: 'feature/test-1',
|
||||
providerId,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockEventBus = {
|
||||
emitAutoModeEvent: vi.fn(),
|
||||
getUnderlyingEmitter: vi.fn().mockReturnValue({}),
|
||||
} as unknown as TypedEventBus;
|
||||
|
||||
mockFeatureStateManager = {
|
||||
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
||||
loadFeature: vi.fn().mockResolvedValue(createFeatureWithProvider()),
|
||||
} as unknown as FeatureStateManager;
|
||||
|
||||
mockAgentExecutor = {
|
||||
execute: vi.fn().mockResolvedValue({ success: true }),
|
||||
} as unknown as AgentExecutor;
|
||||
|
||||
mockTestRunnerService = {
|
||||
startTests: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }),
|
||||
getSession: vi.fn().mockReturnValue({
|
||||
status: 'passed',
|
||||
exitCode: 0,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
getSessionOutput: vi
|
||||
.fn()
|
||||
.mockReturnValue({ success: true, result: { output: 'All tests passed' } }),
|
||||
} as unknown as TestRunnerService;
|
||||
|
||||
mockWorktreeResolver = {
|
||||
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
|
||||
getCurrentBranch: vi.fn().mockResolvedValue('main'),
|
||||
} as unknown as WorktreeResolver;
|
||||
|
||||
mockConcurrencyManager = {
|
||||
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({
|
||||
featureId,
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
isAutoMode: isAutoMode ?? false,
|
||||
})),
|
||||
release: vi.fn(),
|
||||
getRunningFeature: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ConcurrencyManager;
|
||||
|
||||
mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' });
|
||||
mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content');
|
||||
mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined);
|
||||
mockRunAgentFn = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('Previous context');
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(getFeatureDir).mockImplementation(
|
||||
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
||||
);
|
||||
vi.mocked(getPromptCustomization).mockResolvedValue({
|
||||
taskExecution: {
|
||||
implementationInstructions: 'test instructions',
|
||||
playwrightVerificationInstructions: 'test playwright',
|
||||
},
|
||||
} as any);
|
||||
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
|
||||
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
|
||||
|
||||
orchestrator = new PipelineOrchestrator(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockAgentExecutor,
|
||||
mockTestRunnerService,
|
||||
mockWorktreeResolver,
|
||||
mockConcurrencyManager,
|
||||
null,
|
||||
mockUpdateFeatureStatusFn,
|
||||
mockLoadContextFilesFn,
|
||||
mockBuildFeaturePromptFn,
|
||||
mockExecuteFeatureFn,
|
||||
mockRunAgentFn
|
||||
);
|
||||
});
|
||||
|
||||
describe('executePipeline', () => {
|
||||
it('should pass providerId to runAgentFn options when feature has providerId', async () => {
|
||||
const feature = createFeatureWithProvider('moonshot-ai');
|
||||
const context: PipelineContext = {
|
||||
projectPath: '/test/project',
|
||||
featureId: 'feature-1',
|
||||
feature,
|
||||
steps: testSteps,
|
||||
workDir: '/test/project',
|
||||
worktreePath: null,
|
||||
branchName: 'feature/test-1',
|
||||
abortController: new AbortController(),
|
||||
autoLoadClaudeMd: true,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await orchestrator.executePipeline(context);
|
||||
|
||||
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('providerId', 'moonshot-ai');
|
||||
});
|
||||
|
||||
it('should pass undefined providerId when feature has no providerId', async () => {
|
||||
const feature = createFeatureWithProvider(undefined);
|
||||
const context: PipelineContext = {
|
||||
projectPath: '/test/project',
|
||||
featureId: 'feature-1',
|
||||
feature,
|
||||
steps: testSteps,
|
||||
workDir: '/test/project',
|
||||
worktreePath: null,
|
||||
branchName: 'feature/test-1',
|
||||
abortController: new AbortController(),
|
||||
autoLoadClaudeMd: true,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await orchestrator.executePipeline(context);
|
||||
|
||||
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('providerId', undefined);
|
||||
});
|
||||
|
||||
it('should pass status alongside providerId in options', async () => {
|
||||
const feature = createFeatureWithProvider('zhipu');
|
||||
const context: PipelineContext = {
|
||||
projectPath: '/test/project',
|
||||
featureId: 'feature-1',
|
||||
feature,
|
||||
steps: testSteps,
|
||||
workDir: '/test/project',
|
||||
worktreePath: null,
|
||||
branchName: 'feature/test-1',
|
||||
abortController: new AbortController(),
|
||||
autoLoadClaudeMd: true,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await orchestrator.executePipeline(context);
|
||||
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('providerId', 'zhipu');
|
||||
expect(options).toHaveProperty('status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTestStep', () => {
|
||||
it('should pass providerId in test fix agent options when tests fail', async () => {
|
||||
vi.mocked(mockTestRunnerService.getSession)
|
||||
.mockReturnValueOnce({
|
||||
status: 'failed',
|
||||
exitCode: 1,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
} as never)
|
||||
.mockReturnValueOnce({
|
||||
status: 'passed',
|
||||
exitCode: 0,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const feature = createFeatureWithProvider('custom-provider');
|
||||
const context: PipelineContext = {
|
||||
projectPath: '/test/project',
|
||||
featureId: 'feature-1',
|
||||
feature,
|
||||
steps: testSteps,
|
||||
workDir: '/test/project',
|
||||
worktreePath: null,
|
||||
branchName: 'feature/test-1',
|
||||
abortController: new AbortController(),
|
||||
autoLoadClaudeMd: true,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await orchestrator.executeTestStep(context, 'npm test');
|
||||
|
||||
// The fix agent should receive providerId
|
||||
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('providerId', 'custom-provider');
|
||||
}, 15000);
|
||||
|
||||
it('should pass thinkingLevel in test fix agent options', async () => {
|
||||
vi.mocked(mockTestRunnerService.getSession)
|
||||
.mockReturnValueOnce({
|
||||
status: 'failed',
|
||||
exitCode: 1,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
} as never)
|
||||
.mockReturnValueOnce({
|
||||
status: 'passed',
|
||||
exitCode: 0,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const feature = createFeatureWithProvider('moonshot-ai');
|
||||
feature.thinkingLevel = 'high';
|
||||
const context: PipelineContext = {
|
||||
projectPath: '/test/project',
|
||||
featureId: 'feature-1',
|
||||
feature,
|
||||
steps: testSteps,
|
||||
workDir: '/test/project',
|
||||
worktreePath: null,
|
||||
branchName: 'feature/test-1',
|
||||
abortController: new AbortController(),
|
||||
autoLoadClaudeMd: true,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await orchestrator.executeTestStep(context, 'npm test');
|
||||
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('thinkingLevel', 'high');
|
||||
expect(options).toHaveProperty('providerId', 'moonshot-ai');
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Tests for status + providerId coexistence in PipelineOrchestrator options.
|
||||
*
|
||||
* During rebase onto upstream/v1.0.0rc, a merge conflict arose where
|
||||
* upstream added `status: currentStatus` and the incoming branch added
|
||||
* `providerId: feature.providerId`. The conflict resolution kept BOTH fields.
|
||||
*
|
||||
* This test validates that both fields coexist correctly in the options
|
||||
* object passed to runAgentFn in both executePipeline and executeTestStep.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Feature, PipelineStep } from '@automaker/types';
|
||||
import {
|
||||
PipelineOrchestrator,
|
||||
type PipelineContext,
|
||||
type UpdateFeatureStatusFn,
|
||||
type BuildFeaturePromptFn,
|
||||
type ExecuteFeatureFn,
|
||||
type RunAgentFn,
|
||||
} from '../../../src/services/pipeline-orchestrator.js';
|
||||
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
|
||||
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
|
||||
import type { AgentExecutor } from '../../../src/services/agent-executor.js';
|
||||
import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js';
|
||||
import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js';
|
||||
import type { TestRunnerService } from '../../../src/services/test-runner-service.js';
|
||||
import * as secureFs from '../../../src/lib/secure-fs.js';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../../../src/lib/settings-helpers.js';
|
||||
|
||||
vi.mock('../../../src/services/pipeline-service.js', () => ({
|
||||
pipelineService: {
|
||||
isPipelineStatus: vi.fn(),
|
||||
getStepIdFromStatus: vi.fn(),
|
||||
getPipelineConfig: vi.fn(),
|
||||
getNextStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/services/merge-service.js', () => ({
|
||||
performMerge: vi.fn().mockResolvedValue({ success: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/lib/settings-helpers.js', () => ({
|
||||
getPromptCustomization: vi.fn().mockResolvedValue({
|
||||
taskExecution: {
|
||||
implementationInstructions: 'test instructions',
|
||||
playwrightVerificationInstructions: 'test playwright',
|
||||
},
|
||||
}),
|
||||
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
|
||||
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
|
||||
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/lib/sdk-options.js', () => ({
|
||||
validateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeatureDir: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/model-resolver', () => ({
|
||||
resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'),
|
||||
DEFAULT_MODELS: { claude: 'claude-sonnet-4' },
|
||||
}));
|
||||
|
||||
describe('PipelineOrchestrator - status and providerId coexistence', () => {
|
||||
let mockRunAgentFn: RunAgentFn;
|
||||
let orchestrator: PipelineOrchestrator;
|
||||
|
||||
const testSteps: PipelineStep[] = [
|
||||
{
|
||||
id: 'implement',
|
||||
name: 'Implement Feature',
|
||||
order: 1,
|
||||
instructions: 'Implement the feature',
|
||||
colorClass: 'blue',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
const createFeature = (overrides: Partial<Feature> = {}): Feature => ({
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
category: 'test',
|
||||
description: 'Test description',
|
||||
status: 'pipeline_implement',
|
||||
branchName: 'feature/test-1',
|
||||
providerId: 'moonshot-ai',
|
||||
thinkingLevel: 'medium',
|
||||
reasoningEffort: 'high',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createContext = (feature: Feature): PipelineContext => ({
|
||||
projectPath: '/test/project',
|
||||
featureId: feature.id,
|
||||
feature,
|
||||
steps: testSteps,
|
||||
workDir: '/test/project',
|
||||
worktreePath: null,
|
||||
branchName: feature.branchName ?? 'main',
|
||||
abortController: new AbortController(),
|
||||
autoLoadClaudeMd: true,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRunAgentFn = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('Previous context');
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(getFeatureDir).mockImplementation(
|
||||
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
||||
);
|
||||
vi.mocked(getPromptCustomization).mockResolvedValue({
|
||||
taskExecution: {
|
||||
implementationInstructions: 'test instructions',
|
||||
playwrightVerificationInstructions: 'test playwright',
|
||||
},
|
||||
} as any);
|
||||
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
|
||||
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
|
||||
|
||||
const mockEventBus = {
|
||||
emitAutoModeEvent: vi.fn(),
|
||||
getUnderlyingEmitter: vi.fn().mockReturnValue({}),
|
||||
} as unknown as TypedEventBus;
|
||||
|
||||
const mockFeatureStateManager = {
|
||||
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
||||
loadFeature: vi.fn().mockResolvedValue(createFeature()),
|
||||
} as unknown as FeatureStateManager;
|
||||
|
||||
const mockTestRunnerService = {
|
||||
startTests: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }),
|
||||
getSession: vi.fn().mockReturnValue({
|
||||
status: 'passed',
|
||||
exitCode: 0,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
getSessionOutput: vi
|
||||
.fn()
|
||||
.mockReturnValue({ success: true, result: { output: 'All tests passed' } }),
|
||||
} as unknown as TestRunnerService;
|
||||
|
||||
orchestrator = new PipelineOrchestrator(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
{} as AgentExecutor,
|
||||
mockTestRunnerService,
|
||||
{
|
||||
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
|
||||
getCurrentBranch: vi.fn().mockResolvedValue('main'),
|
||||
} as unknown as WorktreeResolver,
|
||||
{
|
||||
acquire: vi.fn().mockImplementation(({ featureId }) => ({
|
||||
featureId,
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
isAutoMode: false,
|
||||
})),
|
||||
release: vi.fn(),
|
||||
getRunningFeature: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ConcurrencyManager,
|
||||
null,
|
||||
vi.fn().mockResolvedValue(undefined),
|
||||
vi.fn().mockResolvedValue({ contextPrompt: 'test context' }),
|
||||
vi.fn().mockReturnValue('Feature prompt content'),
|
||||
vi.fn().mockResolvedValue(undefined),
|
||||
mockRunAgentFn
|
||||
);
|
||||
});
|
||||
|
||||
describe('executePipeline - options object', () => {
|
||||
it('should pass both status and providerId in options', async () => {
|
||||
const feature = createFeature({ providerId: 'moonshot-ai' });
|
||||
const context = createContext(feature);
|
||||
|
||||
await orchestrator.executePipeline(context);
|
||||
|
||||
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('status', 'pipeline_implement');
|
||||
expect(options).toHaveProperty('providerId', 'moonshot-ai');
|
||||
});
|
||||
|
||||
it('should pass status even when providerId is undefined', async () => {
|
||||
const feature = createFeature({ providerId: undefined });
|
||||
const context = createContext(feature);
|
||||
|
||||
await orchestrator.executePipeline(context);
|
||||
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('status', 'pipeline_implement');
|
||||
expect(options).toHaveProperty('providerId', undefined);
|
||||
});
|
||||
|
||||
it('should pass thinkingLevel and reasoningEffort alongside status and providerId', async () => {
|
||||
const feature = createFeature({
|
||||
providerId: 'zhipu',
|
||||
thinkingLevel: 'high',
|
||||
reasoningEffort: 'medium',
|
||||
});
|
||||
const context = createContext(feature);
|
||||
|
||||
await orchestrator.executePipeline(context);
|
||||
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('status', 'pipeline_implement');
|
||||
expect(options).toHaveProperty('providerId', 'zhipu');
|
||||
expect(options).toHaveProperty('thinkingLevel', 'high');
|
||||
expect(options).toHaveProperty('reasoningEffort', 'medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTestStep - options object', () => {
|
||||
it('should pass both status and providerId in test fix agent options', async () => {
|
||||
const feature = createFeature({
|
||||
status: 'running',
|
||||
providerId: 'custom-provider',
|
||||
});
|
||||
const context = createContext(feature);
|
||||
|
||||
const mockTestRunner = orchestrator['testRunnerService'] as any;
|
||||
vi.mocked(mockTestRunner.getSession)
|
||||
.mockReturnValueOnce({
|
||||
status: 'failed',
|
||||
exitCode: 1,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 'passed',
|
||||
exitCode: 0,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
|
||||
await orchestrator.executeTestStep(context, 'npm test');
|
||||
|
||||
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
expect(options).toHaveProperty('status', 'running');
|
||||
expect(options).toHaveProperty('providerId', 'custom-provider');
|
||||
}, 15000);
|
||||
|
||||
it('should pass feature.status (not currentStatus) in test fix context', async () => {
|
||||
const feature = createFeature({
|
||||
status: 'pipeline_test',
|
||||
providerId: 'moonshot-ai',
|
||||
});
|
||||
const context = createContext(feature);
|
||||
|
||||
const mockTestRunner = orchestrator['testRunnerService'] as any;
|
||||
vi.mocked(mockTestRunner.getSession)
|
||||
.mockReturnValueOnce({
|
||||
status: 'failed',
|
||||
exitCode: 1,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 'passed',
|
||||
exitCode: 0,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
|
||||
await orchestrator.executeTestStep(context, 'npm test');
|
||||
|
||||
const options = mockRunAgentFn.mock.calls[0][7];
|
||||
// In test fix context, status should come from context.feature.status
|
||||
expect(options).toHaveProperty('status', 'pipeline_test');
|
||||
expect(options).toHaveProperty('providerId', 'moonshot-ai');
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Integration tests for pipeline summary accumulation across multiple steps.
|
||||
*
|
||||
* These tests verify the end-to-end behavior where:
|
||||
* 1. Each pipeline step produces a summary via agent-executor → callbacks.saveFeatureSummary()
|
||||
* 2. FeatureStateManager.saveFeatureSummary() accumulates summaries with step headers
|
||||
* 3. The emitted auto_mode_summary event contains the full accumulated summary
|
||||
* 4. The UI can use feature.summary (accumulated) instead of extractSummary() (last-only)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { FeatureStateManager } from '@/services/feature-state-manager.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { pipelineService } from '@/services/pipeline-service.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@automaker/utils')>();
|
||||
return {
|
||||
...actual,
|
||||
atomicWriteJson: vi.fn(),
|
||||
readJsonWithRecovery: vi.fn(),
|
||||
logRecoveryWarning: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeatureDir: vi.fn(),
|
||||
getFeaturesDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/notification-service.js', () => ({
|
||||
getNotificationService: vi.fn(() => ({
|
||||
createNotification: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/pipeline-service.js', () => ({
|
||||
pipelineService: {
|
||||
getStepIdFromStatus: vi.fn((status: string) => {
|
||||
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
|
||||
return null;
|
||||
}),
|
||||
getStep: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Pipeline Summary Accumulation (Integration)', () => {
|
||||
let manager: FeatureStateManager;
|
||||
let mockEvents: EventEmitter;
|
||||
|
||||
const baseFeature: Feature = {
|
||||
id: 'pipeline-feature-1',
|
||||
name: 'Pipeline Feature',
|
||||
title: 'Pipeline Feature Title',
|
||||
description: 'A feature going through pipeline steps',
|
||||
status: 'pipeline_step1',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockEvents = {
|
||||
emit: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
};
|
||||
|
||||
const mockFeatureLoader = {
|
||||
syncFeatureToAppSpec: vi.fn(),
|
||||
} as unknown as FeatureLoader;
|
||||
|
||||
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
|
||||
});
|
||||
|
||||
describe('multi-step pipeline summary accumulation', () => {
|
||||
it('should accumulate summaries across three pipeline steps in chronological order', async () => {
|
||||
// --- Step 1: Implementation ---
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'pipeline-feature-1',
|
||||
'## Changes\n- Added auth module\n- Created user service'
|
||||
);
|
||||
|
||||
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(step1Feature.summary).toBe(
|
||||
'### Implementation\n\n## Changes\n- Added auth module\n- Created user service'
|
||||
);
|
||||
|
||||
// --- Step 2: Code Review ---
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Feature.summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'pipeline-feature-1',
|
||||
'## Review Findings\n- Style issues fixed\n- Added error handling'
|
||||
);
|
||||
|
||||
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
|
||||
// --- Step 3: Testing ---
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step3' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step3', summary: step2Feature.summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'pipeline-feature-1',
|
||||
'## Test Results\n- 42 tests pass\n- 98% coverage'
|
||||
);
|
||||
|
||||
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
|
||||
// Verify the full accumulated summary has all three steps separated by ---
|
||||
const expectedSummary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Added auth module',
|
||||
'- Created user service',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'## Review Findings',
|
||||
'- Style issues fixed',
|
||||
'- Added error handling',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Test Results',
|
||||
'- 42 tests pass',
|
||||
'- 98% coverage',
|
||||
].join('\n');
|
||||
|
||||
expect(finalFeature.summary).toBe(expectedSummary);
|
||||
});
|
||||
|
||||
it('should emit the full accumulated summary in auto_mode_summary event', async () => {
|
||||
// Step 1
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 1 output');
|
||||
|
||||
// Verify the event was emitted with correct data
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'pipeline-feature-1',
|
||||
projectPath: '/project',
|
||||
summary: '### Implementation\n\nStep 1 output',
|
||||
});
|
||||
|
||||
// Step 2 (with accumulated summary from step 1)
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: 'pipeline_step2',
|
||||
summary: '### Implementation\n\nStep 1 output',
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 2 output');
|
||||
|
||||
// The event should contain the FULL accumulated summary, not just step 2
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'pipeline-feature-1',
|
||||
projectPath: '/project',
|
||||
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases in pipeline accumulation', () => {
|
||||
it('should normalize a legacy implementation summary before appending pipeline output', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: 'pipeline_step2',
|
||||
summary: 'Implemented authentication and settings updates.',
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Reviewed and approved');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
'### Implementation\n\nImplemented authentication and settings updates.\n\n---\n\n### Code Review\n\nReviewed and approved'
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip persistence when a pipeline step summary is empty', async () => {
|
||||
const existingSummary = '### Step 1\n\nFirst step output';
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step 2', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
// Empty summary should be ignored to avoid persisting blank sections.
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', '');
|
||||
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
expect(mockEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle pipeline step name lookup failure with fallback', async () => {
|
||||
(pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => {
|
||||
throw new Error('Pipeline config not loaded');
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_code_review', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Review output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Fallback: capitalize words from status suffix
|
||||
expect(savedFeature.summary).toBe('### Code Review\n\nReview output');
|
||||
});
|
||||
|
||||
it('should handle summary with special markdown characters in pipeline mode', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const markdownSummary = [
|
||||
'## Changes Made',
|
||||
'- Fixed **critical bug** in `parser.ts`',
|
||||
'- Added `validateInput()` function',
|
||||
'',
|
||||
'```typescript',
|
||||
'const x = 1;',
|
||||
'```',
|
||||
'',
|
||||
'| Column | Value |',
|
||||
'|--------|-------|',
|
||||
'| Tests | Pass |',
|
||||
].join('\n');
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', markdownSummary);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(`### Implementation\n\n${markdownSummary}`);
|
||||
// Verify markdown is preserved
|
||||
expect(savedFeature.summary).toContain('```typescript');
|
||||
expect(savedFeature.summary).toContain('| Column | Value |');
|
||||
});
|
||||
|
||||
it('should correctly handle rapid sequential pipeline steps without data loss', async () => {
|
||||
// Simulate 5 rapid pipeline steps
|
||||
const stepConfigs = [
|
||||
{ name: 'Planning', status: 'pipeline_step1', content: 'Plan created' },
|
||||
{ name: 'Implementation', status: 'pipeline_step2', content: 'Code written' },
|
||||
{ name: 'Code Review', status: 'pipeline_step3', content: 'Review complete' },
|
||||
{ name: 'Testing', status: 'pipeline_step4', content: 'All tests pass' },
|
||||
{ name: 'Refinement', status: 'pipeline_step5', content: 'Code polished' },
|
||||
];
|
||||
|
||||
let currentSummary: string | undefined = undefined;
|
||||
|
||||
for (const step of stepConfigs) {
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: step.name,
|
||||
id: step.status.replace('pipeline_', ''),
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: step.status, summary: currentSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', step.content);
|
||||
|
||||
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
}
|
||||
|
||||
// Final summary should contain all 5 steps
|
||||
expect(currentSummary).toContain('### Planning');
|
||||
expect(currentSummary).toContain('Plan created');
|
||||
expect(currentSummary).toContain('### Implementation');
|
||||
expect(currentSummary).toContain('Code written');
|
||||
expect(currentSummary).toContain('### Code Review');
|
||||
expect(currentSummary).toContain('Review complete');
|
||||
expect(currentSummary).toContain('### Testing');
|
||||
expect(currentSummary).toContain('All tests pass');
|
||||
expect(currentSummary).toContain('### Refinement');
|
||||
expect(currentSummary).toContain('Code polished');
|
||||
|
||||
// Verify there are exactly 4 separators (between 5 steps)
|
||||
const separatorCount = (currentSummary!.match(/\n\n---\n\n/g) || []).length;
|
||||
expect(separatorCount).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI summary display logic', () => {
|
||||
it('should emit accumulated summary that UI can display directly (no extractSummary needed)', async () => {
|
||||
// This test verifies the UI can use feature.summary directly
|
||||
// without needing to call extractSummary() which only returns the last entry
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First step');
|
||||
|
||||
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
|
||||
// Step 2
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second step');
|
||||
|
||||
const emittedEvent = (mockEvents.emit as Mock).mock.calls[0][1];
|
||||
const accumulatedSummary = emittedEvent.summary;
|
||||
|
||||
// The accumulated summary should contain BOTH steps
|
||||
expect(accumulatedSummary).toContain('### Implementation');
|
||||
expect(accumulatedSummary).toContain('First step');
|
||||
expect(accumulatedSummary).toContain('### Testing');
|
||||
expect(accumulatedSummary).toContain('Second step');
|
||||
});
|
||||
|
||||
it('should handle single-step pipeline (no accumulation needed)', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Single step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe('### Implementation\n\nSingle step output');
|
||||
|
||||
// No separator should be present for single step
|
||||
expect(savedFeature.summary).not.toContain('---');
|
||||
});
|
||||
|
||||
it('should preserve chronological order of summaries', async () => {
|
||||
// Step 1
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Alpha', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First');
|
||||
|
||||
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
|
||||
// Step 2
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Beta', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second');
|
||||
|
||||
const finalSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
|
||||
// Verify order: Alpha should come before Beta
|
||||
const alphaIndex = finalSummary!.indexOf('### Alpha');
|
||||
const betaIndex = finalSummary!.indexOf('### Beta');
|
||||
expect(alphaIndex).toBeLessThan(betaIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-pipeline features', () => {
|
||||
it('should overwrite summary for non-pipeline features', async () => {
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: 'in_progress', // Non-pipeline status
|
||||
summary: 'Old summary',
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'New summary');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe('New summary');
|
||||
});
|
||||
|
||||
it('should not add step headers for non-pipeline features', async () => {
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: 'in_progress', // Non-pipeline status
|
||||
summary: undefined,
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Simple summary');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe('Simple summary');
|
||||
expect(savedFeature.summary).not.toContain('###');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary content edge cases', () => {
|
||||
it('should handle summary with unicode characters', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const unicodeSummary = 'Test results: ✅ 42 passed, ❌ 0 failed, 🎉 100% coverage';
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', unicodeSummary);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toContain('✅');
|
||||
expect(savedFeature.summary).toContain('❌');
|
||||
expect(savedFeature.summary).toContain('🎉');
|
||||
});
|
||||
|
||||
it('should handle very long summary content', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
// Generate a very long summary (10KB+)
|
||||
const longContent = 'This is a line of content.\n'.repeat(500);
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', longContent);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary!.length).toBeGreaterThan(10000);
|
||||
});
|
||||
|
||||
it('should handle summary with markdown tables', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const tableSummary = `
|
||||
## Test Results
|
||||
|
||||
| Test Suite | Passed | Failed | Skipped |
|
||||
|------------|--------|--------|---------|
|
||||
| Unit | 42 | 0 | 2 |
|
||||
| Integration| 15 | 0 | 0 |
|
||||
| E2E | 8 | 1 | 0 |
|
||||
`;
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', tableSummary);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toContain('| Test Suite |');
|
||||
expect(savedFeature.summary).toContain('| Unit | 42 |');
|
||||
});
|
||||
|
||||
it('should handle summary with nested markdown headers', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const nestedSummary = `
|
||||
## Main Changes
|
||||
### Backend
|
||||
- Added API endpoints
|
||||
### Frontend
|
||||
- Created components
|
||||
#### Deep nesting
|
||||
- Minor fix
|
||||
`;
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', nestedSummary);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toContain('### Backend');
|
||||
expect(savedFeature.summary).toContain('### Frontend');
|
||||
expect(savedFeature.summary).toContain('#### Deep nesting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence and event ordering', () => {
|
||||
it('should persist summary BEFORE emitting event', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
(atomicWriteJson as Mock).mockImplementation(async () => {
|
||||
callOrder.push('persist');
|
||||
});
|
||||
(mockEvents.emit as Mock).mockImplementation(() => {
|
||||
callOrder.push('emit');
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary');
|
||||
|
||||
expect(callOrder).toEqual(['persist', 'emit']);
|
||||
});
|
||||
|
||||
it('should not emit event if persistence fails (error is caught silently)', async () => {
|
||||
// Note: saveFeatureSummary catches errors internally and logs them
|
||||
// It does NOT re-throw, so the method completes successfully even on error
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
(atomicWriteJson as Mock).mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
// Method completes without throwing (error is logged internally)
|
||||
await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary');
|
||||
|
||||
// Event should NOT be emitted since persistence failed
|
||||
expect(mockEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,28 @@ import {
|
||||
type Credentials,
|
||||
type ProjectSettings,
|
||||
} from '@/types/settings.js';
|
||||
import type { NtfyEndpointConfig } from '@automaker/types';
|
||||
|
||||
describe('settings-service.ts', () => {
|
||||
let testDataDir: string;
|
||||
let testProjectDir: string;
|
||||
let settingsService: SettingsService;
|
||||
|
||||
/**
|
||||
* Helper to create a test ntfy endpoint with sensible defaults
|
||||
*/
|
||||
function createTestNtfyEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
|
||||
return {
|
||||
id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: 'Test Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
|
||||
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
|
||||
@@ -171,6 +187,150 @@ describe('settings-service.ts', () => {
|
||||
expect(updated.theme).toBe('solarized');
|
||||
});
|
||||
|
||||
it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => {
|
||||
const endpoint1 = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'My Ntfy',
|
||||
topic: 'my-topic',
|
||||
});
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [endpoint1] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [],
|
||||
} as any);
|
||||
|
||||
// The empty array should be ignored - existing endpoints should be preserved
|
||||
expect(updated.ntfyEndpoints?.length).toBe(1);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
});
|
||||
|
||||
it('should allow adding new ntfyEndpoints to existing list', async () => {
|
||||
const endpoint1 = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'First Endpoint',
|
||||
topic: 'first-topic',
|
||||
});
|
||||
const endpoint2 = createTestNtfyEndpoint({
|
||||
id: 'endpoint-2',
|
||||
name: 'Second Endpoint',
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'second-topic',
|
||||
authType: 'token',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [endpoint1] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [endpoint1, endpoint2] as any,
|
||||
});
|
||||
|
||||
// Both endpoints should be present
|
||||
expect(updated.ntfyEndpoints?.length).toBe(2);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2');
|
||||
});
|
||||
|
||||
it('should allow updating ntfyEndpoints with non-empty array', async () => {
|
||||
const originalEndpoint = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'Original Name',
|
||||
topic: 'original-topic',
|
||||
});
|
||||
const updatedEndpoint = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'Updated Name',
|
||||
topic: 'updated-topic',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [originalEndpoint] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [updatedEndpoint] as any,
|
||||
});
|
||||
|
||||
// The update should go through with the new values
|
||||
expect(updated.ntfyEndpoints?.length).toBe(1);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name');
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic');
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => {
|
||||
// Start with no endpoints (default state)
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2));
|
||||
|
||||
// Trying to set empty array should be fine when there are no existing endpoints
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [],
|
||||
} as any);
|
||||
|
||||
// Empty array should be set (no data loss because there was nothing to lose)
|
||||
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve ntfyEndpoints while updating other settings', async () => {
|
||||
const endpoint = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'My Endpoint',
|
||||
topic: 'my-topic',
|
||||
});
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
theme: 'dark',
|
||||
ntfyEndpoints: [endpoint] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
// Update theme without sending ntfyEndpoints
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
theme: 'light',
|
||||
});
|
||||
|
||||
// Theme should be updated
|
||||
expect(updated.theme).toBe('light');
|
||||
// ntfyEndpoints should be preserved from existing settings
|
||||
expect(updated.ntfyEndpoints?.length).toBe(1);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
});
|
||||
|
||||
it('should allow clearing ntfyEndpoints with escape hatch flag', async () => {
|
||||
const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' });
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [endpoint] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
// Use escape hatch to intentionally clear ntfyEndpoints
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [],
|
||||
__allowEmptyNtfyEndpoints: true,
|
||||
} as any);
|
||||
|
||||
// The empty array should be applied because escape hatch was used
|
||||
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('should create data directory if it does not exist', async () => {
|
||||
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
||||
const newService = new SettingsService(newDataDir);
|
||||
@@ -562,6 +722,73 @@ describe('settings-service.ts', () => {
|
||||
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
|
||||
});
|
||||
|
||||
it('should migrate ntfyEndpoints from localStorage data', async () => {
|
||||
const localStorageData = {
|
||||
'automaker-storage': JSON.stringify({
|
||||
state: {
|
||||
ntfyEndpoints: [
|
||||
{
|
||||
id: 'endpoint-1',
|
||||
name: 'My Ntfy Server',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'my-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.migratedGlobalSettings).toBe(true);
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.ntfyEndpoints?.length).toBe(1);
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server');
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic');
|
||||
});
|
||||
|
||||
it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => {
|
||||
const localStorageData = {
|
||||
'automaker-storage': JSON.stringify({
|
||||
state: {
|
||||
eventHooks: [
|
||||
{
|
||||
id: 'hook-1',
|
||||
name: 'Test Hook',
|
||||
eventType: 'feature:started',
|
||||
enabled: true,
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
ntfyEndpoints: [
|
||||
{
|
||||
id: 'endpoint-1',
|
||||
name: 'My Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.eventHooks?.length).toBe(1);
|
||||
expect(settings.ntfyEndpoints?.length).toBe(1);
|
||||
expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1');
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
});
|
||||
|
||||
it('should handle direct localStorage values', async () => {
|
||||
const localStorageData = {
|
||||
'automaker:lastProjectDir': '/path/to/project',
|
||||
|
||||
@@ -207,12 +207,21 @@ Let me begin by...
|
||||
|
||||
describe('detectTaskCompleteMarker', () => {
|
||||
it('should detect task complete marker and return task ID', () => {
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toBe('T001');
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toBe('T042');
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toEqual({
|
||||
id: 'T001',
|
||||
summary: undefined,
|
||||
});
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toEqual({
|
||||
id: 'T042',
|
||||
summary: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle marker with summary', () => {
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toBe('T001');
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toEqual({
|
||||
id: 'T001',
|
||||
summary: 'User model created',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when no marker present', () => {
|
||||
@@ -229,7 +238,28 @@ Done with the implementation:
|
||||
|
||||
Moving on to...
|
||||
`;
|
||||
expect(detectTaskCompleteMarker(accumulated)).toBe('T003');
|
||||
expect(detectTaskCompleteMarker(accumulated)).toEqual({
|
||||
id: 'T003',
|
||||
summary: 'Database setup complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('should find marker in the middle of a stream with trailing text', () => {
|
||||
const streamText =
|
||||
'The implementation is complete! [TASK_COMPLETE] T001: Added user model and tests. Now let me check the next task...';
|
||||
expect(detectTaskCompleteMarker(streamText)).toEqual({
|
||||
id: 'T001',
|
||||
summary: 'Added user model and tests. Now let me check the next task...',
|
||||
});
|
||||
});
|
||||
|
||||
it('should find marker in the middle of a stream with multiple tasks and return the FIRST match', () => {
|
||||
const streamText =
|
||||
'[TASK_COMPLETE] T001: Task one done. Continuing... [TASK_COMPLETE] T002: Task two done. Moving on...';
|
||||
expect(detectTaskCompleteMarker(streamText)).toEqual({
|
||||
id: 'T001',
|
||||
summary: 'Task one done. Continuing...',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not confuse with TASK_START marker', () => {
|
||||
@@ -240,6 +270,44 @@ Moving on to...
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] TASK1')).toBeNull();
|
||||
expect(detectTaskCompleteMarker('[TASK_COMPLETE] T1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow brackets in summary text', () => {
|
||||
// Regression test: summaries containing array[index] syntax should not be truncated
|
||||
expect(
|
||||
detectTaskCompleteMarker('[TASK_COMPLETE] T001: Supports array[index] access syntax')
|
||||
).toEqual({
|
||||
id: 'T001',
|
||||
summary: 'Supports array[index] access syntax',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle summary with multiple brackets', () => {
|
||||
expect(
|
||||
detectTaskCompleteMarker('[TASK_COMPLETE] T042: Fixed bug in data[0].items[key] mapping')
|
||||
).toEqual({
|
||||
id: 'T042',
|
||||
summary: 'Fixed bug in data[0].items[key] mapping',
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop at newline in summary', () => {
|
||||
const result = detectTaskCompleteMarker(
|
||||
'[TASK_COMPLETE] T001: First line\nSecond line without marker'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
id: 'T001',
|
||||
summary: 'First line',
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop at next TASK_START marker', () => {
|
||||
expect(
|
||||
detectTaskCompleteMarker('[TASK_COMPLETE] T001: Summary text[TASK_START] T002')
|
||||
).toEqual({
|
||||
id: 'T001',
|
||||
summary: 'Summary text',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPhaseCompleteMarker', () => {
|
||||
@@ -505,6 +573,55 @@ Implementation details.
|
||||
`;
|
||||
expect(extractSummary(text)).toBe('Summary content here.');
|
||||
});
|
||||
|
||||
it('should include ### subsections within the summary (not cut off at ### Root Cause)', () => {
|
||||
const text = `
|
||||
## Summary
|
||||
|
||||
Overview of changes.
|
||||
|
||||
### Root Cause
|
||||
The bug was caused by X.
|
||||
|
||||
### Fix Applied
|
||||
Changed Y to Z.
|
||||
|
||||
## Other Section
|
||||
More content.
|
||||
`;
|
||||
const result = extractSummary(text);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('Overview of changes.');
|
||||
expect(result).toContain('### Root Cause');
|
||||
expect(result).toContain('The bug was caused by X.');
|
||||
expect(result).toContain('### Fix Applied');
|
||||
expect(result).toContain('Changed Y to Z.');
|
||||
expect(result).not.toContain('## Other Section');
|
||||
});
|
||||
|
||||
it('should include ### subsections and stop at next ## header', () => {
|
||||
const text = `
|
||||
## Summary
|
||||
|
||||
Brief intro.
|
||||
|
||||
### Changes
|
||||
- File A modified
|
||||
- File B added
|
||||
|
||||
### Notes
|
||||
Important context.
|
||||
|
||||
## Implementation
|
||||
Details here.
|
||||
`;
|
||||
const result = extractSummary(text);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('Brief intro.');
|
||||
expect(result).toContain('### Changes');
|
||||
expect(result).toContain('### Notes');
|
||||
expect(result).not.toContain('## Implementation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('**Goal**: section (lite planning mode)', () => {
|
||||
@@ -624,7 +741,7 @@ Summary section content.
|
||||
expect(extractSummary('Random text without any summary patterns')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple paragraph summaries (return first paragraph)', () => {
|
||||
it('should include all paragraphs in ## Summary section', () => {
|
||||
const text = `
|
||||
## Summary
|
||||
|
||||
@@ -634,7 +751,89 @@ Second paragraph of summary.
|
||||
|
||||
## Other
|
||||
`;
|
||||
expect(extractSummary(text)).toBe('First paragraph of summary.');
|
||||
const result = extractSummary(text);
|
||||
expect(result).toContain('First paragraph of summary.');
|
||||
expect(result).toContain('Second paragraph of summary.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline accumulated output (multiple <summary> tags)', () => {
|
||||
it('should return only the LAST summary tag from accumulated pipeline output', () => {
|
||||
// Documents WHY the UI needs server-side feature.summary:
|
||||
// When pipeline steps accumulate raw output in agent-output.md, each step
|
||||
// writes its own <summary> tag. extractSummary takes only the LAST match,
|
||||
// losing all previous steps' summaries.
|
||||
const accumulatedOutput = `
|
||||
## Step 1: Code Review
|
||||
|
||||
Some review output...
|
||||
|
||||
<summary>
|
||||
## Code Review Summary
|
||||
- Found 3 issues
|
||||
- Suggested 2 improvements
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
## Step 2: Testing
|
||||
|
||||
Running tests...
|
||||
|
||||
<summary>
|
||||
## Testing Summary
|
||||
- All 15 tests pass
|
||||
- Coverage at 92%
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(accumulatedOutput);
|
||||
// Only the LAST summary tag is returned - the Code Review summary is lost
|
||||
expect(result).toBe('## Testing Summary\n- All 15 tests pass\n- Coverage at 92%');
|
||||
expect(result).not.toContain('Code Review');
|
||||
});
|
||||
|
||||
it('should return only the LAST summary from three pipeline steps', () => {
|
||||
const accumulatedOutput = `
|
||||
<summary>Step 1: Implementation complete</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
<summary>Step 2: Code review findings</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
<summary>Step 3: All tests passing</summary>
|
||||
`;
|
||||
const result = extractSummary(accumulatedOutput);
|
||||
expect(result).toBe('Step 3: All tests passing');
|
||||
expect(result).not.toContain('Step 1');
|
||||
expect(result).not.toContain('Step 2');
|
||||
});
|
||||
|
||||
it('should handle accumulated output where only one step has a summary tag', () => {
|
||||
const accumulatedOutput = `
|
||||
## Step 1: Implementation
|
||||
Some raw output without summary tags...
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
## Step 2: Testing
|
||||
|
||||
<summary>
|
||||
## Test Results
|
||||
- All tests pass
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(accumulatedOutput);
|
||||
expect(result).toBe('## Test Results\n- All tests pass');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,25 @@ branch refs/heads/feature-y
|
||||
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
|
||||
});
|
||||
|
||||
it('should normalize refs/heads and trim when resolving target branch', async () => {
|
||||
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));
|
||||
|
||||
const result = await resolver.findWorktreeForBranch(
|
||||
'/Users/dev/project',
|
||||
' refs/heads/feature-x '
|
||||
);
|
||||
|
||||
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
|
||||
});
|
||||
|
||||
it('should normalize remote-style target branch names', async () => {
|
||||
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));
|
||||
|
||||
const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'origin/feature-x');
|
||||
|
||||
expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x'));
|
||||
});
|
||||
|
||||
it('should return null when branch not found', async () => {
|
||||
mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' }));
|
||||
|
||||
|
||||
48
apps/server/tests/unit/types/pipeline-types.test.ts
Normal file
48
apps/server/tests/unit/types/pipeline-types.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
|
||||
describe('isPipelineStatus', () => {
|
||||
it('should return true for valid pipeline statuses', () => {
|
||||
expect(isPipelineStatus('pipeline_step1')).toBe(true);
|
||||
expect(isPipelineStatus('pipeline_testing')).toBe(true);
|
||||
expect(isPipelineStatus('pipeline_code_review')).toBe(true);
|
||||
expect(isPipelineStatus('pipeline_complete')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for pipeline_ prefix with any non-empty suffix', () => {
|
||||
expect(isPipelineStatus('pipeline_')).toBe(false); // Empty suffix is invalid
|
||||
expect(isPipelineStatus('pipeline_123')).toBe(true);
|
||||
expect(isPipelineStatus('pipeline_step_abc_123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-pipeline statuses', () => {
|
||||
expect(isPipelineStatus('in_progress')).toBe(false);
|
||||
expect(isPipelineStatus('backlog')).toBe(false);
|
||||
expect(isPipelineStatus('ready')).toBe(false);
|
||||
expect(isPipelineStatus('interrupted')).toBe(false);
|
||||
expect(isPipelineStatus('waiting_approval')).toBe(false);
|
||||
expect(isPipelineStatus('verified')).toBe(false);
|
||||
expect(isPipelineStatus('completed')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null and undefined', () => {
|
||||
expect(isPipelineStatus(null)).toBe(false);
|
||||
expect(isPipelineStatus(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isPipelineStatus('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for partial matches', () => {
|
||||
expect(isPipelineStatus('pipeline')).toBe(false);
|
||||
expect(isPipelineStatus('pipelin_step1')).toBe(false);
|
||||
expect(isPipelineStatus('Pipeline_step1')).toBe(false);
|
||||
expect(isPipelineStatus('PIPELINE_step1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for pipeline prefix embedded in longer string', () => {
|
||||
expect(isPipelineStatus('not_pipeline_step1')).toBe(false);
|
||||
expect(isPipelineStatus('my_pipeline_step')).toBe(false);
|
||||
});
|
||||
});
|
||||
563
apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts
Normal file
563
apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* End-to-end integration tests for agent output summary display flow.
|
||||
*
|
||||
* These tests validate the complete flow from:
|
||||
* 1. Server-side summary accumulation (FeatureStateManager.saveFeatureSummary)
|
||||
* 2. Event emission with accumulated summary (auto_mode_summary event)
|
||||
* 3. UI-side summary retrieval (feature.summary via API)
|
||||
* 4. UI-side summary parsing and display (parsePhaseSummaries, extractSummary)
|
||||
*
|
||||
* The tests simulate what happens when:
|
||||
* - A feature goes through multiple pipeline steps
|
||||
* - Each step produces a summary
|
||||
* - The server accumulates all summaries
|
||||
* - The UI displays the accumulated summary
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { FeatureStateManager } from '@/services/feature-state-manager.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { pipelineService } from '@/services/pipeline-service.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@automaker/utils')>();
|
||||
return {
|
||||
...actual,
|
||||
atomicWriteJson: vi.fn(),
|
||||
readJsonWithRecovery: vi.fn(),
|
||||
logRecoveryWarning: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeatureDir: vi.fn(),
|
||||
getFeaturesDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/notification-service.js', () => ({
|
||||
getNotificationService: vi.fn(() => ({
|
||||
createNotification: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/pipeline-service.js', () => ({
|
||||
pipelineService: {
|
||||
getStepIdFromStatus: vi.fn((status: string) => {
|
||||
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
|
||||
return null;
|
||||
}),
|
||||
getStep: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// UI-side parsing functions (mirrored from apps/ui/src/lib/log-parser.ts)
|
||||
// ============================================================================
|
||||
|
||||
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
|
||||
const phaseSummaries = new Map<string, string>();
|
||||
if (!summary || !summary.trim()) return phaseSummaries;
|
||||
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
for (const section of sections) {
|
||||
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const phaseName = headerMatch[1].trim().toLowerCase();
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
phaseSummaries.set(phaseName, content);
|
||||
}
|
||||
}
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
function extractSummary(rawOutput: string): string | null {
|
||||
if (!rawOutput || !rawOutput.trim()) return null;
|
||||
|
||||
const regexesToTry: Array<{
|
||||
regex: RegExp;
|
||||
processor: (m: RegExpMatchArray) => string;
|
||||
}> = [
|
||||
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
|
||||
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
|
||||
];
|
||||
|
||||
for (const { regex, processor } of regexesToTry) {
|
||||
const matches = [...rawOutput.matchAll(regex)];
|
||||
if (matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
return processor(lastMatch).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
if (!summary || !summary.trim()) return false;
|
||||
return summary.includes('\n\n---\n\n') && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first summary candidate that contains non-whitespace content.
|
||||
* Mirrors getFirstNonEmptySummary from apps/ui/src/lib/summary-selection.ts
|
||||
*/
|
||||
function getFirstNonEmptySummary(...candidates: (string | null | undefined)[]): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests for helper functions
|
||||
// ============================================================================
|
||||
|
||||
describe('getFirstNonEmptySummary', () => {
|
||||
it('should return the first non-empty string', () => {
|
||||
expect(getFirstNonEmptySummary(null, undefined, 'first', 'second')).toBe('first');
|
||||
});
|
||||
|
||||
it('should skip null and undefined candidates', () => {
|
||||
expect(getFirstNonEmptySummary(null, undefined, 'valid')).toBe('valid');
|
||||
});
|
||||
|
||||
it('should skip whitespace-only strings', () => {
|
||||
expect(getFirstNonEmptySummary(' ', '\n\t', 'actual content')).toBe('actual content');
|
||||
});
|
||||
|
||||
it('should return null when all candidates are empty', () => {
|
||||
expect(getFirstNonEmptySummary(null, undefined, '', ' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no candidates provided', () => {
|
||||
expect(getFirstNonEmptySummary()).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty string as invalid', () => {
|
||||
expect(getFirstNonEmptySummary('', 'valid')).toBe('valid');
|
||||
});
|
||||
|
||||
it('should prefer first valid candidate', () => {
|
||||
expect(getFirstNonEmptySummary('first', 'second', 'third')).toBe('first');
|
||||
});
|
||||
|
||||
it('should handle strings with only spaces as invalid', () => {
|
||||
expect(getFirstNonEmptySummary(' ', ' \n ', 'valid')).toBe('valid');
|
||||
});
|
||||
|
||||
it('should accept strings with content surrounded by whitespace', () => {
|
||||
expect(getFirstNonEmptySummary(' content with spaces ')).toBe(' content with spaces ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Output Summary E2E Flow', () => {
|
||||
let manager: FeatureStateManager;
|
||||
let mockEvents: EventEmitter;
|
||||
|
||||
const baseFeature: Feature = {
|
||||
id: 'e2e-feature-1',
|
||||
name: 'E2E Feature',
|
||||
title: 'E2E Feature Title',
|
||||
description: 'A feature going through complete pipeline',
|
||||
status: 'pipeline_implementation',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockEvents = {
|
||||
emit: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
};
|
||||
|
||||
const mockFeatureLoader = {
|
||||
syncFeatureToAppSpec: vi.fn(),
|
||||
} as unknown as FeatureLoader;
|
||||
|
||||
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
});
|
||||
|
||||
describe('complete pipeline flow: server accumulation → UI display', () => {
|
||||
it('should maintain complete summary across all pipeline steps', async () => {
|
||||
// ===== STEP 1: Implementation =====
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'e2e-feature-1',
|
||||
'## Changes\n- Created auth module\n- Added user service'
|
||||
);
|
||||
|
||||
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
const step1Summary = step1Feature.summary;
|
||||
|
||||
// Verify server-side accumulation format
|
||||
expect(step1Summary).toBe(
|
||||
'### Implementation\n\n## Changes\n- Created auth module\n- Added user service'
|
||||
);
|
||||
|
||||
// Verify UI can parse this summary
|
||||
const phases1 = parsePhaseSummaries(step1Summary);
|
||||
expect(phases1.size).toBe(1);
|
||||
expect(phases1.get('implementation')).toContain('Created auth module');
|
||||
|
||||
// ===== STEP 2: Code Review =====
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Code Review',
|
||||
id: 'code_review',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_code_review', summary: step1Summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'e2e-feature-1',
|
||||
'## Review Results\n- Approved with minor suggestions'
|
||||
);
|
||||
|
||||
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
const step2Summary = step2Feature.summary;
|
||||
|
||||
// Verify accumulation now has both steps
|
||||
expect(step2Summary).toContain('### Implementation');
|
||||
expect(step2Summary).toContain('Created auth module');
|
||||
expect(step2Summary).toContain('### Code Review');
|
||||
expect(step2Summary).toContain('Approved with minor suggestions');
|
||||
expect(step2Summary).toContain('\n\n---\n\n'); // Separator
|
||||
|
||||
// Verify UI can parse accumulated summary
|
||||
expect(isAccumulatedSummary(step2Summary)).toBe(true);
|
||||
const phases2 = parsePhaseSummaries(step2Summary);
|
||||
expect(phases2.size).toBe(2);
|
||||
expect(phases2.get('implementation')).toContain('Created auth module');
|
||||
expect(phases2.get('code review')).toContain('Approved with minor suggestions');
|
||||
|
||||
// ===== STEP 3: Testing =====
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_testing', summary: step2Summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'e2e-feature-1',
|
||||
'## Test Results\n- 42 tests pass\n- 98% coverage'
|
||||
);
|
||||
|
||||
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
const finalSummary = finalFeature.summary;
|
||||
|
||||
// Verify final accumulation has all three steps
|
||||
expect(finalSummary).toContain('### Implementation');
|
||||
expect(finalSummary).toContain('Created auth module');
|
||||
expect(finalSummary).toContain('### Code Review');
|
||||
expect(finalSummary).toContain('Approved with minor suggestions');
|
||||
expect(finalSummary).toContain('### Testing');
|
||||
expect(finalSummary).toContain('42 tests pass');
|
||||
|
||||
// Verify UI-side parsing of complete pipeline
|
||||
expect(isAccumulatedSummary(finalSummary)).toBe(true);
|
||||
const finalPhases = parsePhaseSummaries(finalSummary);
|
||||
expect(finalPhases.size).toBe(3);
|
||||
|
||||
// Verify chronological order (implementation before testing)
|
||||
const summaryLines = finalSummary!.split('\n');
|
||||
const implIndex = summaryLines.findIndex((l) => l.includes('### Implementation'));
|
||||
const reviewIndex = summaryLines.findIndex((l) => l.includes('### Code Review'));
|
||||
const testIndex = summaryLines.findIndex((l) => l.includes('### Testing'));
|
||||
expect(implIndex).toBeLessThan(reviewIndex);
|
||||
expect(reviewIndex).toBeLessThan(testIndex);
|
||||
});
|
||||
|
||||
it('should emit events with accumulated summaries for real-time UI updates', async () => {
|
||||
// Step 1
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 1 output');
|
||||
|
||||
// Verify event emission
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'e2e-feature-1',
|
||||
projectPath: '/project',
|
||||
summary: '### Implementation\n\nStep 1 output',
|
||||
});
|
||||
|
||||
// Step 2
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: 'pipeline_testing',
|
||||
summary: '### Implementation\n\nStep 1 output',
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 2 output');
|
||||
|
||||
// Event should contain FULL accumulated summary
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'e2e-feature-1',
|
||||
projectPath: '/project',
|
||||
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI display logic: feature.summary vs extractSummary()', () => {
|
||||
it('should prefer feature.summary (server-accumulated) over extractSummary() (last only)', () => {
|
||||
// Simulate what the server has accumulated
|
||||
const featureSummary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Created feature',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Results',
|
||||
'- All tests pass',
|
||||
].join('\n');
|
||||
|
||||
// Simulate raw agent output (only contains last summary)
|
||||
const rawOutput = `
|
||||
Working on tests...
|
||||
|
||||
<summary>
|
||||
## Results
|
||||
- All tests pass
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
|
||||
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
|
||||
|
||||
// Should use server-accumulated summary
|
||||
expect(displaySummary).toBe(featureSummary);
|
||||
expect(displaySummary).toContain('### Implementation');
|
||||
expect(displaySummary).toContain('### Testing');
|
||||
|
||||
// If server summary was missing, only last summary would be shown
|
||||
const fallbackSummary = extractSummary(rawOutput);
|
||||
expect(fallbackSummary).not.toContain('Implementation');
|
||||
expect(fallbackSummary).toContain('All tests pass');
|
||||
});
|
||||
|
||||
it('should handle legacy features without server accumulation', () => {
|
||||
// Legacy features have no feature.summary
|
||||
const featureSummary = undefined;
|
||||
|
||||
// Raw output contains the summary
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
## Implementation Complete
|
||||
- Created the feature
|
||||
- All tests pass
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
|
||||
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
|
||||
|
||||
// Should fall back to client-side extraction
|
||||
expect(displaySummary).toContain('Implementation Complete');
|
||||
expect(displaySummary).toContain('All tests pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error recovery and edge cases', () => {
|
||||
it('should gracefully handle pipeline interruption', async () => {
|
||||
// Step 1 completes
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Implementation done');
|
||||
|
||||
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
|
||||
// Pipeline gets interrupted (status changes but summary is preserved)
|
||||
// When user views the feature later, the summary should still be available
|
||||
expect(step1Summary).toBe('### Implementation\n\nImplementation done');
|
||||
|
||||
// UI can still parse the partial pipeline
|
||||
const phases = parsePhaseSummaries(step1Summary);
|
||||
expect(phases.size).toBe(1);
|
||||
expect(phases.get('implementation')).toBe('Implementation done');
|
||||
});
|
||||
|
||||
it('should handle very large accumulated summaries', async () => {
|
||||
// Generate large content for each step
|
||||
const generateLargeContent = (stepNum: number) => {
|
||||
const lines = [`## Step ${stepNum} Changes`];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
lines.push(
|
||||
`- Change ${i}: This is a detailed description of the change made during step ${stepNum}`
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
// Simulate 5 pipeline steps with large content
|
||||
let currentSummary: string | undefined = undefined;
|
||||
const stepNames = ['Planning', 'Implementation', 'Code Review', 'Testing', 'Refinement'];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: stepNames[i],
|
||||
id: stepNames[i].toLowerCase().replace(' ', '_'),
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: `pipeline_${stepNames[i].toLowerCase().replace(' ', '_')}`,
|
||||
summary: currentSummary,
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', generateLargeContent(i + 1));
|
||||
|
||||
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
}
|
||||
|
||||
// Final summary should be large but still parseable
|
||||
expect(currentSummary!.length).toBeGreaterThan(5000);
|
||||
expect(isAccumulatedSummary(currentSummary)).toBe(true);
|
||||
|
||||
const phases = parsePhaseSummaries(currentSummary);
|
||||
expect(phases.size).toBe(5);
|
||||
|
||||
// Verify all steps are present
|
||||
for (const stepName of stepNames) {
|
||||
expect(phases.has(stepName.toLowerCase())).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('query invalidation simulation', () => {
|
||||
it('should trigger UI refetch on auto_mode_summary event', async () => {
|
||||
// This test documents the expected behavior:
|
||||
// When saveFeatureSummary is called, it emits auto_mode_summary event
|
||||
// The UI's use-query-invalidation.ts invalidates the feature query
|
||||
// This causes a refetch of the feature, getting the updated summary
|
||||
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Summary content');
|
||||
|
||||
// Verify event was emitted (triggers React Query invalidation)
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'auto-mode:event',
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'e2e-feature-1',
|
||||
summary: expect.any(String),
|
||||
})
|
||||
);
|
||||
|
||||
// The UI would then:
|
||||
// 1. Receive the event via WebSocket
|
||||
// 2. Invalidate the feature query
|
||||
// 3. Refetch the feature (GET /api/features/:id)
|
||||
// 4. Display the updated feature.summary
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* KEY E2E FLOW SUMMARY:
|
||||
*
|
||||
* 1. PIPELINE EXECUTION:
|
||||
* - Feature starts with status='pipeline_implementation'
|
||||
* - Agent runs and produces summary
|
||||
* - FeatureStateManager.saveFeatureSummary() accumulates with step header
|
||||
* - Status advances to 'pipeline_testing'
|
||||
* - Process repeats for each step
|
||||
*
|
||||
* 2. SERVER-SIDE ACCUMULATION:
|
||||
* - First step: `### Implementation\n\n<content>`
|
||||
* - Second step: `### Implementation\n\n<content>\n\n---\n\n### Testing\n\n<content>`
|
||||
* - Pattern continues with each step
|
||||
*
|
||||
* 3. EVENT EMISSION:
|
||||
* - auto_mode_summary event contains FULL accumulated summary
|
||||
* - UI receives event via WebSocket
|
||||
* - React Query invalidates feature query
|
||||
* - Feature is refetched with updated summary
|
||||
*
|
||||
* 4. UI DISPLAY:
|
||||
* - AgentOutputModal uses: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
|
||||
* - feature.summary is preferred (contains all steps)
|
||||
* - extractSummary() is fallback (last summary only)
|
||||
* - parsePhaseSummaries() can split into individual phases for UI
|
||||
*
|
||||
* 5. FALLBACK FOR LEGACY:
|
||||
* - Old features may not have feature.summary
|
||||
* - UI falls back to extracting from raw output
|
||||
* - Only last summary is available in this case
|
||||
*/
|
||||
403
apps/server/tests/unit/ui/agent-output-summary-priority.test.ts
Normal file
403
apps/server/tests/unit/ui/agent-output-summary-priority.test.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Unit tests for the agent output summary priority logic.
|
||||
*
|
||||
* These tests verify the summary display logic used in AgentOutputModal
|
||||
* where the UI must choose between server-accumulated summaries and
|
||||
* client-side extracted summaries.
|
||||
*
|
||||
* Priority order (from agent-output-modal.tsx):
|
||||
* 1. feature.summary (server-accumulated, contains all pipeline steps)
|
||||
* 2. extractSummary(output) (client-side fallback, last summary only)
|
||||
*
|
||||
* This priority is crucial for pipeline features where the server-side
|
||||
* accumulation provides the complete history of all step summaries.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Import the actual extractSummary function to ensure test behavior matches production
|
||||
import { extractSummary } from '../../../../ui/src/lib/log-parser.ts';
|
||||
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
|
||||
|
||||
/**
|
||||
* Simulates the summary priority logic from AgentOutputModal.
|
||||
*
|
||||
* Priority:
|
||||
* 1. feature?.summary (server-accumulated)
|
||||
* 2. extractSummary(output) (client-side fallback)
|
||||
*/
|
||||
function getDisplaySummary(
|
||||
featureSummary: string | undefined | null,
|
||||
rawOutput: string
|
||||
): string | null {
|
||||
return getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
|
||||
}
|
||||
|
||||
describe('Agent Output Summary Priority Logic', () => {
|
||||
describe('priority order: feature.summary over extractSummary', () => {
|
||||
it('should use feature.summary when available (server-accumulated wins)', () => {
|
||||
const featureSummary = '### Step 1\n\nFirst step\n\n---\n\n### Step 2\n\nSecond step';
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Only the last summary is extracted client-side
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
// Server-accumulated summary should be used, not client-side extraction
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).toContain('### Step 1');
|
||||
expect(result).toContain('### Step 2');
|
||||
expect(result).not.toContain('Only the last summary');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is undefined', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
This is the only summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
expect(result).toBe('This is the only summary');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is null', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Client-side extracted summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(null, rawOutput);
|
||||
|
||||
expect(result).toBe('Client-side extracted summary');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is empty string', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Fallback content
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary('', rawOutput);
|
||||
|
||||
// Empty string is falsy, so fallback is used
|
||||
expect(result).toBe('Fallback content');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is whitespace only', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Fallback for whitespace summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(' \n ', rawOutput);
|
||||
|
||||
expect(result).toBe('Fallback for whitespace summary');
|
||||
});
|
||||
|
||||
it('should preserve original server summary formatting when non-empty after trim', () => {
|
||||
const featureSummary = '\n### Implementation\n\n- Added API route\n';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, '');
|
||||
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).toContain('### Implementation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline step accumulation scenarios', () => {
|
||||
it('should display all pipeline steps when using server-accumulated summary', () => {
|
||||
// This simulates a feature that went through 3 pipeline steps
|
||||
const featureSummary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Created new module',
|
||||
'- Added tests',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'## Review Results',
|
||||
'- Approved with minor suggestions',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Test Results',
|
||||
'- All 42 tests pass',
|
||||
'- Coverage: 98%',
|
||||
].join('\n');
|
||||
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Only testing step visible in raw output
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
// All pipeline steps should be visible
|
||||
expect(result).toContain('### Implementation');
|
||||
expect(result).toContain('### Code Review');
|
||||
expect(result).toContain('### Testing');
|
||||
expect(result).toContain('All 42 tests pass');
|
||||
});
|
||||
|
||||
it('should display only last summary when server-side accumulation not available', () => {
|
||||
// When feature.summary is not available, only the last summary is shown
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Step 1: Implementation complete
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>
|
||||
Step 2: Code review complete
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>
|
||||
Step 3: Testing complete
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
// Only the LAST summary should be shown (client-side fallback behavior)
|
||||
expect(result).toBe('Step 3: Testing complete');
|
||||
expect(result).not.toContain('Step 1');
|
||||
expect(result).not.toContain('Step 2');
|
||||
});
|
||||
|
||||
it('should handle single-step pipeline (no accumulation needed)', () => {
|
||||
const featureSummary = '### Implementation\n\nCreated the feature';
|
||||
const rawOutput = '';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).not.toContain('---'); // No separator for single step
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null when both feature.summary and extractSummary are unavailable', () => {
|
||||
const rawOutput = 'No summary tags here, just regular output.';
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when rawOutput is empty and no feature summary', () => {
|
||||
const result = getDisplaySummary(undefined, '');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when rawOutput is whitespace only', () => {
|
||||
const result = getDisplaySummary(undefined, ' \n\n ');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should use client-side fallback when feature.summary is empty string (falsy)', () => {
|
||||
// Empty string is falsy in JavaScript, so fallback is correctly used.
|
||||
// This is the expected behavior - an empty summary has no value to display.
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Fallback content when server summary is empty
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// Empty string is falsy, so fallback is used
|
||||
const result = getDisplaySummary('', rawOutput);
|
||||
expect(result).toBe('Fallback content when server summary is empty');
|
||||
});
|
||||
|
||||
it('should behave identically when feature is null vs feature.summary is undefined', () => {
|
||||
// This test verifies that the behavior is consistent whether:
|
||||
// - The feature object itself is null/undefined
|
||||
// - The feature object exists but summary property is undefined
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Client-side extracted summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// Both scenarios should use client-side fallback
|
||||
const resultWithUndefined = getDisplaySummary(undefined, rawOutput);
|
||||
const resultWithNull = getDisplaySummary(null, rawOutput);
|
||||
|
||||
expect(resultWithUndefined).toBe('Client-side extracted summary');
|
||||
expect(resultWithNull).toBe('Client-side extracted summary');
|
||||
expect(resultWithUndefined).toBe(resultWithNull);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown content preservation', () => {
|
||||
it('should preserve markdown formatting in server-accumulated summary', () => {
|
||||
const featureSummary = `### Code Review
|
||||
|
||||
## Changes Made
|
||||
- Fixed **critical bug** in \`parser.ts\`
|
||||
- Added \`validateInput()\` function
|
||||
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Unit | Pass |`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, '');
|
||||
|
||||
expect(result).toContain('**critical bug**');
|
||||
expect(result).toContain('`parser.ts`');
|
||||
expect(result).toContain('```typescript');
|
||||
expect(result).toContain('| Test | Result |');
|
||||
});
|
||||
|
||||
it('should preserve unicode in server-accumulated summary', () => {
|
||||
const featureSummary = '### Testing\n\n✅ 42 passed\n❌ 0 failed\n🎉 100% coverage';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, '');
|
||||
|
||||
expect(result).toContain('✅');
|
||||
expect(result).toContain('❌');
|
||||
expect(result).toContain('🎉');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle typical pipeline feature with server accumulation', () => {
|
||||
// Simulates a real pipeline feature that went through Implementation → Testing
|
||||
const featureSummary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created UserProfile component
|
||||
- Added authentication middleware
|
||||
- Updated API endpoints
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
## Test Results
|
||||
- Unit tests: 15 passed
|
||||
- Integration tests: 8 passed
|
||||
- E2E tests: 3 passed`;
|
||||
|
||||
const rawOutput = `
|
||||
Working on the feature...
|
||||
|
||||
<summary>
|
||||
## Test Results
|
||||
- Unit tests: 15 passed
|
||||
- Integration tests: 8 passed
|
||||
- E2E tests: 3 passed
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
// Both steps should be visible
|
||||
expect(result).toContain('### Implementation');
|
||||
expect(result).toContain('### Testing');
|
||||
expect(result).toContain('UserProfile component');
|
||||
expect(result).toContain('15 passed');
|
||||
});
|
||||
|
||||
it('should handle non-pipeline feature (single summary)', () => {
|
||||
// Non-pipeline features have a single summary, no accumulation
|
||||
const featureSummary = '## Implementation Complete\n- Created the feature\n- All tests pass';
|
||||
const rawOutput = '';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).not.toContain('###'); // No step headers for non-pipeline
|
||||
});
|
||||
|
||||
it('should handle legacy feature without server summary (fallback)', () => {
|
||||
// Legacy features may not have feature.summary set
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Legacy implementation from before server-side accumulation
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
expect(result).toBe('Legacy implementation from before server-side accumulation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view mode determination logic', () => {
|
||||
/**
|
||||
* Simulates the effectiveViewMode logic from agent-output-modal.tsx line 86
|
||||
* Default to 'summary' if summary is available, otherwise 'parsed'
|
||||
*/
|
||||
function getEffectiveViewMode(
|
||||
viewMode: string | null,
|
||||
summary: string | null
|
||||
): 'summary' | 'parsed' {
|
||||
return (viewMode ?? (summary ? 'summary' : 'parsed')) as 'summary' | 'parsed';
|
||||
}
|
||||
|
||||
it('should default to summary view when server summary is available', () => {
|
||||
const summary = '### Implementation\n\nContent';
|
||||
const result = getEffectiveViewMode(null, summary);
|
||||
expect(result).toBe('summary');
|
||||
});
|
||||
|
||||
it('should default to summary view when client-side extraction succeeds', () => {
|
||||
const summary = 'Extracted from raw output';
|
||||
const result = getEffectiveViewMode(null, summary);
|
||||
expect(result).toBe('summary');
|
||||
});
|
||||
|
||||
it('should default to parsed view when no summary is available', () => {
|
||||
const result = getEffectiveViewMode(null, null);
|
||||
expect(result).toBe('parsed');
|
||||
});
|
||||
|
||||
it('should respect explicit view mode selection over default', () => {
|
||||
const summary = 'Summary is available';
|
||||
expect(getEffectiveViewMode('raw', summary)).toBe('raw');
|
||||
expect(getEffectiveViewMode('parsed', summary)).toBe('parsed');
|
||||
expect(getEffectiveViewMode('changes', summary)).toBe('changes');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* KEY ARCHITECTURE INSIGHT:
|
||||
*
|
||||
* The priority order (feature.summary > extractSummary(output)) is essential for
|
||||
* pipeline features because:
|
||||
*
|
||||
* 1. Server-side accumulation (FeatureStateManager.saveFeatureSummary) collects
|
||||
* ALL step summaries with headers and separators in chronological order.
|
||||
*
|
||||
* 2. Client-side extractSummary() only returns the LAST summary tag from raw output,
|
||||
* losing all previous step summaries.
|
||||
*
|
||||
* 3. The UI must prefer feature.summary to display the complete history of all
|
||||
* pipeline steps to the user.
|
||||
*
|
||||
* For non-pipeline features (single execution), both sources contain the same
|
||||
* summary, so the priority doesn't matter. But for pipeline features, using the
|
||||
* wrong source would result in incomplete information display.
|
||||
*/
|
||||
68
apps/server/tests/unit/ui/log-parser-mixed-format.test.ts
Normal file
68
apps/server/tests/unit/ui/log-parser-mixed-format.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseAllPhaseSummaries,
|
||||
parsePhaseSummaries,
|
||||
extractPhaseSummary,
|
||||
extractImplementationSummary,
|
||||
isAccumulatedSummary,
|
||||
} from '../../../../ui/src/lib/log-parser.ts';
|
||||
|
||||
describe('log-parser mixed summary format compatibility', () => {
|
||||
const mixedSummary = [
|
||||
'Implemented core auth flow and API wiring.',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'Addressed lint warnings and improved error handling.',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'All tests passing.',
|
||||
].join('\n');
|
||||
|
||||
it('treats leading headerless section as Implementation phase', () => {
|
||||
const phases = parsePhaseSummaries(mixedSummary);
|
||||
|
||||
expect(phases.get('implementation')).toBe('Implemented core auth flow and API wiring.');
|
||||
expect(phases.get('code review')).toBe('Addressed lint warnings and improved error handling.');
|
||||
expect(phases.get('testing')).toBe('All tests passing.');
|
||||
});
|
||||
|
||||
it('returns implementation summary from mixed format', () => {
|
||||
expect(extractImplementationSummary(mixedSummary)).toBe(
|
||||
'Implemented core auth flow and API wiring.'
|
||||
);
|
||||
});
|
||||
|
||||
it('includes Implementation as the first parsed phase entry', () => {
|
||||
const entries = parseAllPhaseSummaries(mixedSummary);
|
||||
|
||||
expect(entries[0]).toMatchObject({
|
||||
phaseName: 'Implementation',
|
||||
content: 'Implemented core auth flow and API wiring.',
|
||||
});
|
||||
expect(entries.map((entry) => entry.phaseName)).toEqual([
|
||||
'Implementation',
|
||||
'Code Review',
|
||||
'Testing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts specific phase summaries from mixed format', () => {
|
||||
expect(extractPhaseSummary(mixedSummary, 'Implementation')).toBe(
|
||||
'Implemented core auth flow and API wiring.'
|
||||
);
|
||||
expect(extractPhaseSummary(mixedSummary, 'Code Review')).toBe(
|
||||
'Addressed lint warnings and improved error handling.'
|
||||
);
|
||||
expect(extractPhaseSummary(mixedSummary, 'Testing')).toBe('All tests passing.');
|
||||
});
|
||||
|
||||
it('treats mixed format as accumulated summary', () => {
|
||||
expect(isAccumulatedSummary(mixedSummary)).toBe(true);
|
||||
});
|
||||
});
|
||||
973
apps/server/tests/unit/ui/log-parser-phase-summary.test.ts
Normal file
973
apps/server/tests/unit/ui/log-parser-phase-summary.test.ts
Normal file
@@ -0,0 +1,973 @@
|
||||
/**
|
||||
* Unit tests for log-parser phase summary parsing functions.
|
||||
*
|
||||
* These functions are used to parse accumulated summaries that contain multiple
|
||||
* pipeline step summaries separated by `---` and identified by `### StepName` headers.
|
||||
*
|
||||
* Functions tested:
|
||||
* - parsePhaseSummaries: Parses the entire accumulated summary into a Map
|
||||
* - extractPhaseSummary: Extracts a specific phase's content
|
||||
* - extractImplementationSummary: Extracts implementation phase content (convenience)
|
||||
* - isAccumulatedSummary: Checks if a summary is in accumulated format
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Mirror the functions from apps/ui/src/lib/log-parser.ts
|
||||
// (We can't import directly because it's a UI file)
|
||||
|
||||
/**
|
||||
* Parses an accumulated summary string into individual phase summaries.
|
||||
*/
|
||||
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
|
||||
const phaseSummaries = new Map<string, string>();
|
||||
|
||||
if (!summary || !summary.trim()) {
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
// Split by the horizontal rule separator
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
|
||||
for (const section of sections) {
|
||||
// Match the phase header pattern: ### Phase Name
|
||||
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const phaseName = headerMatch[1].trim().toLowerCase();
|
||||
// Extract content after the header (skip the header line and leading newlines)
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
phaseSummaries.set(phaseName, content);
|
||||
}
|
||||
}
|
||||
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a specific phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
const normalizedPhaseName = phaseName.toLowerCase();
|
||||
return phaseSummaries.get(normalizedPhaseName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the implementation phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractImplementationSummary(summary: string | undefined): string | null {
|
||||
if (!summary || !summary.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
|
||||
// Try exact match first
|
||||
const implementationContent = phaseSummaries.get('implementation');
|
||||
if (implementationContent) {
|
||||
return implementationContent;
|
||||
}
|
||||
|
||||
// Fallback: find any phase containing "implement"
|
||||
for (const [phaseName, content] of phaseSummaries) {
|
||||
if (phaseName.includes('implement')) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// If no phase summaries found, the summary might not be in accumulated format
|
||||
// (legacy or non-pipeline feature). In this case, return the whole summary
|
||||
// if it looks like a single summary (no phase headers).
|
||||
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a summary string is in the accumulated multi-phase format.
|
||||
*/
|
||||
function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
if (!summary || !summary.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for the presence of phase headers with separator
|
||||
const hasMultiplePhases =
|
||||
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
|
||||
|
||||
return hasMultiplePhases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single phase entry in an accumulated summary.
|
||||
*/
|
||||
interface PhaseSummaryEntry {
|
||||
/** The phase name (e.g., "Implementation", "Testing", "Code Review") */
|
||||
phaseName: string;
|
||||
/** The content of this phase's summary */
|
||||
content: string;
|
||||
/** The original header line (e.g., "### Implementation") */
|
||||
header: string;
|
||||
}
|
||||
|
||||
/** Default phase name used for non-accumulated summaries */
|
||||
const DEFAULT_PHASE_NAME = 'Summary';
|
||||
|
||||
/**
|
||||
* Parses an accumulated summary into individual phase entries.
|
||||
* Returns phases in the order they appear in the summary.
|
||||
*/
|
||||
function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] {
|
||||
const entries: PhaseSummaryEntry[] = [];
|
||||
|
||||
if (!summary || !summary.trim()) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Check if this is an accumulated summary (has phase headers)
|
||||
if (!summary.includes('### ')) {
|
||||
// Not an accumulated summary - return as single entry with generic name
|
||||
return [
|
||||
{ phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` },
|
||||
];
|
||||
}
|
||||
|
||||
// Split by the horizontal rule separator
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
|
||||
for (const section of sections) {
|
||||
// Match the phase header pattern: ### Phase Name
|
||||
const headerMatch = section.match(/^(###\s+)(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const header = headerMatch[0].trim();
|
||||
const phaseName = headerMatch[2].trim();
|
||||
// Extract content after the header (skip the header line and leading newlines)
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
entries.push({ phaseName, content, header });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe('parsePhaseSummaries', () => {
|
||||
describe('basic parsing', () => {
|
||||
it('should parse single phase summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
- Added unit tests`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('implementation')).toBe(
|
||||
'## Changes Made\n- Created new module\n- Added unit tests'
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse multiple phase summaries', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
## Test Results
|
||||
- All tests pass`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('implementation')).toBe('## Changes Made\n- Created new module');
|
||||
expect(result.get('testing')).toBe('## Test Results\n- All tests pass');
|
||||
});
|
||||
|
||||
it('should handle three or more phases', () => {
|
||||
const summary = `### Planning
|
||||
|
||||
Plan created
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Code written
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Tests pass
|
||||
|
||||
---
|
||||
|
||||
### Refinement
|
||||
|
||||
Code polished`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(4);
|
||||
expect(result.get('planning')).toBe('Plan created');
|
||||
expect(result.get('implementation')).toBe('Code written');
|
||||
expect(result.get('testing')).toBe('Tests pass');
|
||||
expect(result.get('refinement')).toBe('Code polished');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty map for undefined summary', () => {
|
||||
const result = parsePhaseSummaries(undefined);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty map for null summary', () => {
|
||||
const result = parsePhaseSummaries(null as unknown as string);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty map for empty string', () => {
|
||||
const result = parsePhaseSummaries('');
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty map for whitespace-only string', () => {
|
||||
const result = parsePhaseSummaries(' \n\n ');
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle summary without phase headers', () => {
|
||||
const summary = 'Just some regular content without headers';
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle section without header after separator', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content here
|
||||
|
||||
---
|
||||
|
||||
This section has no header`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('implementation')).toBe('Content here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase name normalization', () => {
|
||||
it('should normalize phase names to lowercase', () => {
|
||||
const summary = `### IMPLEMENTATION
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.has('implementation')).toBe(true);
|
||||
expect(result.has('IMPLEMENTATION')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle mixed case phase names', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.has('code review')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve spaces in multi-word phase names', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('code review')).toBe('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content preservation', () => {
|
||||
it('should preserve markdown formatting in content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Heading
|
||||
- **Bold text**
|
||||
- \`code\`
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
const content = result.get('implementation');
|
||||
|
||||
expect(content).toContain('**Bold text**');
|
||||
expect(content).toContain('`code`');
|
||||
expect(content).toContain('```typescript');
|
||||
});
|
||||
|
||||
it('should preserve unicode in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Results: ✅ 42 passed, ❌ 0 failed`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('testing')).toContain('✅');
|
||||
expect(result.get('testing')).toContain('❌');
|
||||
});
|
||||
|
||||
it('should preserve tables in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Unit | Pass |`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('testing')).toContain('| Test | Result |');
|
||||
});
|
||||
|
||||
it('should handle empty phase content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('implementation')).toBe('');
|
||||
expect(result.get('testing')).toBe('Content');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPhaseSummary', () => {
|
||||
describe('extraction by phase name', () => {
|
||||
it('should extract specified phase content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation content
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content');
|
||||
expect(extractPhaseSummary(summary, 'Testing')).toBe('Testing content');
|
||||
});
|
||||
|
||||
it('should be case-insensitive for phase name', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'implementation')).toBe('Content');
|
||||
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Content');
|
||||
expect(extractPhaseSummary(summary, 'ImPlEmEnTaTiOn')).toBe('Content');
|
||||
});
|
||||
|
||||
it('should return null for non-existent phase', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'NonExistent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null for undefined summary', () => {
|
||||
expect(extractPhaseSummary(undefined, 'Implementation')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty summary', () => {
|
||||
expect(extractPhaseSummary('', 'Implementation')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle whitespace in phase name', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'Code Review')).toBe('Content');
|
||||
expect(extractPhaseSummary(summary, 'code review')).toBe('Content');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImplementationSummary', () => {
|
||||
describe('exact match', () => {
|
||||
it('should extract implementation phase by exact name', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created feature
|
||||
- Added tests
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Tests pass`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('## Changes Made\n- Created feature\n- Added tests');
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const summary = `### IMPLEMENTATION
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractImplementationSummary(summary)).toBe('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial match fallback', () => {
|
||||
it('should find phase containing "implement"', () => {
|
||||
const summary = `### Feature Implementation
|
||||
|
||||
Content here`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Content here');
|
||||
});
|
||||
|
||||
it('should find phase containing "implementation"', () => {
|
||||
const summary = `### Implementation Phase
|
||||
|
||||
Content here`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Content here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy/non-accumulated summary handling', () => {
|
||||
it('should return full summary if no phase headers present', () => {
|
||||
const summary = `## Changes Made
|
||||
- Created feature
|
||||
- Added tests`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe(summary);
|
||||
});
|
||||
|
||||
it('should return null if summary has phase headers but no implementation', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Tests pass
|
||||
|
||||
---
|
||||
|
||||
### Review
|
||||
|
||||
Review complete`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should not return full summary if it contains phase headers', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Tests pass`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null for undefined summary', () => {
|
||||
expect(extractImplementationSummary(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
expect(extractImplementationSummary('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for whitespace-only string', () => {
|
||||
expect(extractImplementationSummary(' \n\n ')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAccumulatedSummary', () => {
|
||||
describe('accumulated format detection', () => {
|
||||
it('should return true for accumulated summary with separator and headers', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for accumulated summary with multiple phases', () => {
|
||||
const summary = `### Phase 1
|
||||
|
||||
Content 1
|
||||
|
||||
---
|
||||
|
||||
### Phase 2
|
||||
|
||||
Content 2
|
||||
|
||||
---
|
||||
|
||||
### Phase 3
|
||||
|
||||
Content 3`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for accumulated summary with just one phase and separator', () => {
|
||||
// Even a single phase with a separator suggests it's in accumulated format
|
||||
const summary = `### Implementation
|
||||
|
||||
Content
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
More content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-accumulated format detection', () => {
|
||||
it('should return false for summary without separator', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Just content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for summary with separator but no headers', () => {
|
||||
const summary = `Content
|
||||
|
||||
---
|
||||
|
||||
More content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for simple text summary', () => {
|
||||
const summary = 'Just a simple summary without any special formatting';
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for markdown summary without phase headers', () => {
|
||||
const summary = `## Changes Made
|
||||
- Created feature
|
||||
- Added tests`;
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return false for undefined summary', () => {
|
||||
expect(isAccumulatedSummary(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null summary', () => {
|
||||
expect(isAccumulatedSummary(null as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isAccumulatedSummary('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for whitespace-only string', () => {
|
||||
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: Full parsing workflow', () => {
|
||||
it('should correctly parse typical server-accumulated pipeline summary', () => {
|
||||
// This simulates what FeatureStateManager.saveFeatureSummary() produces
|
||||
const summary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Added auth module',
|
||||
'- Created user service',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'## Review Results',
|
||||
'- Style issues fixed',
|
||||
'- Added error handling',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Test Results',
|
||||
'- 42 tests pass',
|
||||
'- 98% coverage',
|
||||
].join('\n');
|
||||
|
||||
// Verify isAccumulatedSummary
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
|
||||
// Verify parsePhaseSummaries
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
expect(phases.size).toBe(3);
|
||||
expect(phases.get('implementation')).toContain('Added auth module');
|
||||
expect(phases.get('code review')).toContain('Style issues fixed');
|
||||
expect(phases.get('testing')).toContain('42 tests pass');
|
||||
|
||||
// Verify extractPhaseSummary
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toContain('Added auth module');
|
||||
expect(extractPhaseSummary(summary, 'Code Review')).toContain('Style issues fixed');
|
||||
expect(extractPhaseSummary(summary, 'Testing')).toContain('42 tests pass');
|
||||
|
||||
// Verify extractImplementationSummary
|
||||
expect(extractImplementationSummary(summary)).toContain('Added auth module');
|
||||
});
|
||||
|
||||
it('should handle legacy non-pipeline summary correctly', () => {
|
||||
// Legacy features have simple summaries without accumulation
|
||||
const summary = `## Implementation Complete
|
||||
- Created the feature
|
||||
- All tests pass`;
|
||||
|
||||
// Should NOT be detected as accumulated
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
|
||||
// parsePhaseSummaries should return empty
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
expect(phases.size).toBe(0);
|
||||
|
||||
// extractPhaseSummary should return null
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toBeNull();
|
||||
|
||||
// extractImplementationSummary should return the full summary (legacy handling)
|
||||
expect(extractImplementationSummary(summary)).toBe(summary);
|
||||
});
|
||||
|
||||
it('should handle single-step pipeline summary', () => {
|
||||
// A single pipeline step still gets the header but no separator
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes
|
||||
- Created the feature`;
|
||||
|
||||
// Should NOT be detected as accumulated (no separator)
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
|
||||
// parsePhaseSummaries should still extract the single phase
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
expect(phases.size).toBe(1);
|
||||
expect(phases.get('implementation')).toContain('Created the feature');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* KEY ARCHITECTURE NOTES:
|
||||
*
|
||||
* 1. The accumulated summary format uses:
|
||||
* - `### PhaseName` for step headers
|
||||
* - `\n\n---\n\n` as separator between steps
|
||||
*
|
||||
* 2. Phase names are normalized to lowercase in the Map for case-insensitive lookup.
|
||||
*
|
||||
* 3. Legacy summaries (non-pipeline features) don't have phase headers and should
|
||||
* be returned as-is by extractImplementationSummary.
|
||||
*
|
||||
* 4. isAccumulatedSummary() checks for BOTH separator AND phase headers to be
|
||||
* confident that the summary is in the accumulated format.
|
||||
*
|
||||
* 5. The server-side FeatureStateManager.saveFeatureSummary() is responsible for
|
||||
* creating summaries in this accumulated format.
|
||||
*/
|
||||
|
||||
describe('parseAllPhaseSummaries', () => {
|
||||
describe('basic parsing', () => {
|
||||
it('should parse single phase summary into array with one entry', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
- Added unit tests`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[0].content).toBe('## Changes Made\n- Created new module\n- Added unit tests');
|
||||
expect(result[0].header).toBe('### Implementation');
|
||||
});
|
||||
|
||||
it('should parse multiple phase summaries in order', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
## Test Results
|
||||
- All tests pass`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
// Verify order is preserved
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[0].content).toBe('## Changes Made\n- Created new module');
|
||||
expect(result[1].phaseName).toBe('Testing');
|
||||
expect(result[1].content).toBe('## Test Results\n- All tests pass');
|
||||
});
|
||||
|
||||
it('should parse three or more phases in correct order', () => {
|
||||
const summary = `### Planning
|
||||
|
||||
Plan created
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Code written
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Tests pass
|
||||
|
||||
---
|
||||
|
||||
### Refinement
|
||||
|
||||
Code polished`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(4);
|
||||
expect(result[0].phaseName).toBe('Planning');
|
||||
expect(result[1].phaseName).toBe('Implementation');
|
||||
expect(result[2].phaseName).toBe('Testing');
|
||||
expect(result[3].phaseName).toBe('Refinement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-accumulated summary handling', () => {
|
||||
it('should return single entry for summary without phase headers', () => {
|
||||
const summary = `## Changes Made
|
||||
- Created feature
|
||||
- Added tests`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Summary');
|
||||
expect(result[0].content).toBe(summary);
|
||||
});
|
||||
|
||||
it('should return single entry for simple text summary', () => {
|
||||
const summary = 'Just a simple summary without any special formatting';
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Summary');
|
||||
expect(result[0].content).toBe(summary);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty array for undefined summary', () => {
|
||||
const result = parseAllPhaseSummaries(undefined);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
const result = parseAllPhaseSummaries('');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array for whitespace-only string', () => {
|
||||
const result = parseAllPhaseSummaries(' \n\n ');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle section without header after separator', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content here
|
||||
|
||||
---
|
||||
|
||||
This section has no header`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content preservation', () => {
|
||||
it('should preserve markdown formatting in content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Heading
|
||||
- **Bold text**
|
||||
- \`code\`
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
const content = result[0].content;
|
||||
|
||||
expect(content).toContain('**Bold text**');
|
||||
expect(content).toContain('`code`');
|
||||
expect(content).toContain('```typescript');
|
||||
});
|
||||
|
||||
it('should preserve unicode in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Results: ✅ 42 passed, ❌ 0 failed`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].content).toContain('✅');
|
||||
expect(result[0].content).toContain('❌');
|
||||
});
|
||||
|
||||
it('should preserve tables in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Unit | Pass |`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].content).toContain('| Test | Result |');
|
||||
});
|
||||
|
||||
it('should handle empty phase content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].content).toBe('');
|
||||
expect(result[1].content).toBe('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('header preservation', () => {
|
||||
it('should preserve original header text', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].header).toBe('### Code Review');
|
||||
});
|
||||
|
||||
it('should preserve phase name with original casing', () => {
|
||||
const summary = `### CODE REVIEW
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].phaseName).toBe('CODE REVIEW');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chronological order preservation', () => {
|
||||
it('should maintain order: Alpha before Beta before Gamma', () => {
|
||||
const summary = `### Alpha
|
||||
|
||||
First
|
||||
|
||||
---
|
||||
|
||||
### Beta
|
||||
|
||||
Second
|
||||
|
||||
---
|
||||
|
||||
### Gamma
|
||||
|
||||
Third`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
const names = result.map((e) => e.phaseName);
|
||||
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
|
||||
});
|
||||
|
||||
it('should preserve typical pipeline order', () => {
|
||||
const summary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Added auth module',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'## Review Results',
|
||||
'- Style issues fixed',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Test Results',
|
||||
'- 42 tests pass',
|
||||
].join('\n');
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[1].phaseName).toBe('Code Review');
|
||||
expect(result[2].phaseName).toBe('Testing');
|
||||
});
|
||||
});
|
||||
});
|
||||
453
apps/server/tests/unit/ui/log-parser-summary.test.ts
Normal file
453
apps/server/tests/unit/ui/log-parser-summary.test.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Unit tests for the UI's log-parser extractSummary() function.
|
||||
*
|
||||
* These tests document the behavior of extractSummary() which is used as a
|
||||
* CLIENT-SIDE FALLBACK when feature.summary (server-accumulated) is not available.
|
||||
*
|
||||
* IMPORTANT: extractSummary() returns only the LAST <summary> tag from raw output.
|
||||
* For pipeline features with multiple steps, the server-side FeatureStateManager
|
||||
* accumulates all step summaries into feature.summary, which the UI prefers.
|
||||
*
|
||||
* The tests below verify that extractSummary() correctly:
|
||||
* - Returns the LAST summary when multiple exist (mimicking pipeline accumulation)
|
||||
* - Handles various summary formats (<summary> tags, markdown headers)
|
||||
* - Returns null when no summary is found
|
||||
* - Handles edge cases like empty input and malformed tags
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Recreate the extractSummary logic from apps/ui/src/lib/log-parser.ts
|
||||
// We can't import directly because it's a UI file, so we mirror the logic here
|
||||
|
||||
/**
|
||||
* Cleans up fragmented streaming text by removing spurious newlines
|
||||
*/
|
||||
function cleanFragmentedText(content: string): string {
|
||||
let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2');
|
||||
cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>');
|
||||
cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts summary content from raw log output
|
||||
* Returns the LAST summary text if found, or null if no summary exists
|
||||
*/
|
||||
function extractSummary(rawOutput: string): string | null {
|
||||
if (!rawOutput || !rawOutput.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedOutput = cleanFragmentedText(rawOutput);
|
||||
|
||||
const regexesToTry: Array<{
|
||||
regex: RegExp;
|
||||
processor: (m: RegExpMatchArray) => string;
|
||||
}> = [
|
||||
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
|
||||
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
|
||||
{
|
||||
regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm,
|
||||
processor: (m) => `## ${m[1]}\n${m[2]}`,
|
||||
},
|
||||
{
|
||||
regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
|
||||
processor: (m) => m[2],
|
||||
},
|
||||
{
|
||||
regex:
|
||||
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
|
||||
processor: (m) => m[2],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { regex, processor } of regexesToTry) {
|
||||
const matches = [...cleanedOutput.matchAll(regex)];
|
||||
if (matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
return cleanFragmentedText(processor(lastMatch)).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('log-parser extractSummary (UI fallback)', () => {
|
||||
describe('basic summary extraction', () => {
|
||||
it('should extract summary from <summary> tags', () => {
|
||||
const output = `
|
||||
Some agent output...
|
||||
|
||||
<summary>
|
||||
## Changes Made
|
||||
- Fixed the bug in parser.ts
|
||||
- Added error handling
|
||||
</summary>
|
||||
|
||||
More output...
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('## Changes Made\n- Fixed the bug in parser.ts\n- Added error handling');
|
||||
});
|
||||
|
||||
it('should prefer <summary> tags over markdown headers', () => {
|
||||
const output = `
|
||||
## Summary
|
||||
|
||||
Markdown summary here.
|
||||
|
||||
<summary>
|
||||
XML summary here.
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('XML summary here.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple summaries (pipeline accumulation scenario)', () => {
|
||||
it('should return ONLY the LAST summary tag when multiple exist', () => {
|
||||
// This is the key behavior for pipeline features:
|
||||
// extractSummary returns only the LAST, which is why server-side
|
||||
// accumulation is needed for multi-step pipelines
|
||||
const output = `
|
||||
## Step 1: Code Review
|
||||
|
||||
<summary>
|
||||
- Found 3 issues
|
||||
- Approved with changes
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Testing
|
||||
|
||||
<summary>
|
||||
- All tests pass
|
||||
- Coverage 95%
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('- All tests pass\n- Coverage 95%');
|
||||
expect(result).not.toContain('Code Review');
|
||||
expect(result).not.toContain('Found 3 issues');
|
||||
});
|
||||
|
||||
it('should return ONLY the LAST summary from three pipeline steps', () => {
|
||||
const output = `
|
||||
<summary>Step 1 complete</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>Step 2 complete</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>Step 3 complete - all done!</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('Step 3 complete - all done!');
|
||||
expect(result).not.toContain('Step 1');
|
||||
expect(result).not.toContain('Step 2');
|
||||
});
|
||||
|
||||
it('should handle mixed summary formats across pipeline steps', () => {
|
||||
const output = `
|
||||
## Step 1
|
||||
|
||||
<summary>
|
||||
Implementation done
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Step 2
|
||||
|
||||
## Summary
|
||||
Review complete
|
||||
|
||||
---
|
||||
|
||||
## Step 3
|
||||
|
||||
<summary>
|
||||
All tests passing
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// The <summary> tag format takes priority, and returns the LAST match
|
||||
expect(result).toBe('All tests passing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority order of summary patterns', () => {
|
||||
it('should try patterns in priority order: <summary> first, then markdown headers', () => {
|
||||
// When both <summary> tags and markdown headers exist,
|
||||
// <summary> tags should take priority
|
||||
const output = `
|
||||
## Summary
|
||||
|
||||
This markdown summary should be ignored.
|
||||
|
||||
<summary>
|
||||
This XML summary should be used.
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('This XML summary should be used.');
|
||||
expect(result).not.toContain('ignored');
|
||||
});
|
||||
|
||||
it('should fall back to Feature/Changes/Implementation headers when no <summary> tag', () => {
|
||||
// Note: The regex for these headers requires content before the header
|
||||
// (^ at start or preceded by newline). Adding some content before.
|
||||
const output = `
|
||||
Agent output here...
|
||||
|
||||
## Feature
|
||||
|
||||
New authentication system with OAuth support.
|
||||
|
||||
## Next
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// Should find the Feature header and include it in result
|
||||
// Note: Due to regex behavior, it captures content until next ##
|
||||
expect(result).toContain('## Feature');
|
||||
});
|
||||
|
||||
it('should fall back to completion phrases when no structured summary found', () => {
|
||||
const output = `
|
||||
Working on the feature...
|
||||
Making progress...
|
||||
|
||||
All tasks completed successfully. The feature is ready.
|
||||
|
||||
🔧 Tool: Bash
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('All tasks completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null for empty string', () => {
|
||||
expect(extractSummary('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for whitespace-only string', () => {
|
||||
expect(extractSummary(' \n\n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no summary pattern found', () => {
|
||||
expect(extractSummary('Random agent output without any summary patterns')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle malformed <summary> tags gracefully', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
This summary is never closed...
|
||||
`;
|
||||
// Without closing tag, the regex won't match
|
||||
expect(extractSummary(output)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty <summary> tags', () => {
|
||||
const output = `
|
||||
<summary></summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe(''); // Empty string is valid
|
||||
});
|
||||
|
||||
it('should handle <summary> tags with only whitespace', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe(''); // Trimmed to empty string
|
||||
});
|
||||
|
||||
it('should handle summary with markdown code blocks', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
## Changes
|
||||
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
Done!
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('```typescript');
|
||||
expect(result).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('should handle summary with special characters', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
Fixed bug in parser.ts: "quotes" and 'apostrophes'
|
||||
Special chars: <>&$@#%^*
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('"quotes"');
|
||||
expect(result).toContain('<>&$@#%^*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fragmented streaming text handling', () => {
|
||||
it('should handle fragmented <summary> tags from streaming', () => {
|
||||
// Sometimes streaming providers split text like "<sum\n\nmary>"
|
||||
const output = `
|
||||
<sum
|
||||
|
||||
mary>
|
||||
Fixed the issue
|
||||
</sum
|
||||
|
||||
mary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// The cleanFragmentedText function should normalize this
|
||||
expect(result).toBe('Fixed the issue');
|
||||
});
|
||||
|
||||
it('should handle fragmented text within summary content', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
Fixed the bug in par
|
||||
ser.ts
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// cleanFragmentedText should join "par\n\nser" into "parser"
|
||||
expect(result).toBe('Fixed the bug in parser.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion phrase detection', () => {
|
||||
it('should extract "All tasks completed" summaries', () => {
|
||||
const output = `
|
||||
Some output...
|
||||
|
||||
All tasks completed successfully. The feature is ready for review.
|
||||
|
||||
🔧 Tool: Bash
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('All tasks completed');
|
||||
});
|
||||
|
||||
it("should extract I've completed summaries", () => {
|
||||
const output = `
|
||||
Working on feature...
|
||||
|
||||
I've successfully implemented the feature with all requirements met.
|
||||
|
||||
🔧 Tool: Read
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain("I've successfully implemented");
|
||||
});
|
||||
|
||||
it('should extract "I have finished" summaries', () => {
|
||||
const output = `
|
||||
Implementation phase...
|
||||
|
||||
I have finished the implementation.
|
||||
|
||||
📋 Planning
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('I have finished');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world pipeline scenarios', () => {
|
||||
it('should handle typical multi-step pipeline output (returns last only)', () => {
|
||||
// This test documents WHY server-side accumulation is essential:
|
||||
// extractSummary only returns the last step's summary
|
||||
const output = `
|
||||
📋 Planning Mode: Full
|
||||
|
||||
🔧 Tool: Read
|
||||
Input: {"file_path": "src/parser.ts"}
|
||||
|
||||
<summary>
|
||||
## Code Review
|
||||
- Analyzed parser.ts
|
||||
- Found potential improvements
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
🔧 Tool: Edit
|
||||
Input: {"file_path": "src/parser.ts"}
|
||||
|
||||
<summary>
|
||||
## Implementation
|
||||
- Applied suggested improvements
|
||||
- Updated tests
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
🔧 Tool: Bash
|
||||
Input: {"command": "npm test"}
|
||||
|
||||
<summary>
|
||||
## Testing
|
||||
- All 42 tests pass
|
||||
- No regressions detected
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// Only the LAST summary is returned
|
||||
expect(result).toBe('## Testing\n- All 42 tests pass\n- No regressions detected');
|
||||
// Earlier summaries are lost
|
||||
expect(result).not.toContain('Code Review');
|
||||
expect(result).not.toContain('Implementation');
|
||||
});
|
||||
|
||||
it('should handle single-step non-pipeline output', () => {
|
||||
// For non-pipeline features, extractSummary works correctly
|
||||
const output = `
|
||||
Working on feature...
|
||||
|
||||
<summary>
|
||||
## Implementation Complete
|
||||
- Created new component
|
||||
- Added unit tests
|
||||
- Updated documentation
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('Implementation Complete');
|
||||
expect(result).toContain('Created new component');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* These tests verify the UI fallback behavior for summary extraction.
|
||||
*
|
||||
* KEY INSIGHT: The extractSummary() function returns only the LAST summary,
|
||||
* which is why the server-side FeatureStateManager.saveFeatureSummary() method
|
||||
* accumulates all step summaries into feature.summary.
|
||||
*
|
||||
* The UI's AgentOutputModal component uses this priority:
|
||||
* 1. feature.summary (server-accumulated, contains all steps)
|
||||
* 2. extractSummary(output) (client-side fallback, last summary only)
|
||||
*
|
||||
* For pipeline features, this ensures all step summaries are displayed.
|
||||
*/
|
||||
533
apps/server/tests/unit/ui/phase-summary-parser.test.ts
Normal file
533
apps/server/tests/unit/ui/phase-summary-parser.test.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Unit tests for the UI's log-parser phase summary parsing functions.
|
||||
*
|
||||
* These tests verify the behavior of:
|
||||
* - parsePhaseSummaries(): Parses accumulated summary into individual phases
|
||||
* - extractPhaseSummary(): Extracts a specific phase's summary
|
||||
* - extractImplementationSummary(): Extracts only the implementation phase
|
||||
* - isAccumulatedSummary(): Checks if summary is in accumulated format
|
||||
*
|
||||
* The accumulated summary format uses markdown headers with `###` for phase names
|
||||
* and `---` as separators between phases.
|
||||
*
|
||||
* TODO: These test helper functions are mirrored from apps/ui/src/lib/log-parser.ts
|
||||
* because server-side tests cannot import from the UI module. If the production
|
||||
* implementation changes, these tests may pass while production fails.
|
||||
* Consider adding an integration test that validates the actual UI parsing behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ============================================================================
|
||||
// MIRRORED FUNCTIONS from apps/ui/src/lib/log-parser.ts
|
||||
// ============================================================================
|
||||
// NOTE: These functions are mirrored from the UI implementation because
|
||||
// server-side tests cannot import from apps/ui/. Keep these in sync with the
|
||||
// production implementation. The UI implementation includes additional
|
||||
// handling for getPhaseSections/leadingImplementationSection for backward
|
||||
// compatibility with mixed formats.
|
||||
|
||||
/**
|
||||
* Parses an accumulated summary string into individual phase summaries.
|
||||
*/
|
||||
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
|
||||
const phaseSummaries = new Map<string, string>();
|
||||
|
||||
if (!summary || !summary.trim()) {
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
// Split by the horizontal rule separator
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
|
||||
for (const section of sections) {
|
||||
// Match the phase header pattern: ### Phase Name
|
||||
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const phaseName = headerMatch[1].trim().toLowerCase();
|
||||
// Extract content after the header (skip the header line and leading newlines)
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
phaseSummaries.set(phaseName, content);
|
||||
}
|
||||
}
|
||||
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a specific phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
const normalizedPhaseName = phaseName.toLowerCase();
|
||||
return phaseSummaries.get(normalizedPhaseName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the implementation phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractImplementationSummary(summary: string | undefined): string | null {
|
||||
if (!summary || !summary.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
|
||||
// Try exact match first
|
||||
const implementationContent = phaseSummaries.get('implementation');
|
||||
if (implementationContent) {
|
||||
return implementationContent;
|
||||
}
|
||||
|
||||
// Fallback: find any phase containing "implement"
|
||||
for (const [phaseName, content] of phaseSummaries) {
|
||||
if (phaseName.includes('implement')) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// If no phase summaries found, the summary might not be in accumulated format
|
||||
// (legacy or non-pipeline feature). In this case, return the whole summary
|
||||
// if it looks like a single summary (no phase headers).
|
||||
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a summary string is in the accumulated multi-phase format.
|
||||
*/
|
||||
function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
if (!summary || !summary.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for the presence of phase headers with separator
|
||||
const hasMultiplePhases =
|
||||
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
|
||||
|
||||
return hasMultiplePhases;
|
||||
}
|
||||
|
||||
describe('phase summary parser', () => {
|
||||
describe('parsePhaseSummaries', () => {
|
||||
it('should parse single phase summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Created auth module with login functionality.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('implementation')).toBe('Created auth module with login functionality.');
|
||||
});
|
||||
|
||||
it('should parse multiple phase summaries', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Created auth module.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
All tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Approved with minor suggestions.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get('implementation')).toBe('Created auth module.');
|
||||
expect(result.get('testing')).toBe('All tests pass.');
|
||||
expect(result.get('code review')).toBe('Approved with minor suggestions.');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(parsePhaseSummaries('').size).toBe(0);
|
||||
expect(parsePhaseSummaries(undefined).size).toBe(0);
|
||||
expect(parsePhaseSummaries(' \n\n ').size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle phase names with spaces', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Review findings here.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('code review')).toBe('Review findings here.');
|
||||
});
|
||||
|
||||
it('should normalize phase names to lowercase', () => {
|
||||
const summary = `### IMPLEMENTATION
|
||||
|
||||
Content here.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('implementation')).toBe('Content here.');
|
||||
expect(result.get('IMPLEMENTATION')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle content with markdown', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Fixed bug in parser.ts
|
||||
- Added error handling
|
||||
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('implementation')).toContain('## Changes Made');
|
||||
expect(result.get('implementation')).toContain('```typescript');
|
||||
});
|
||||
|
||||
it('should return empty map for non-accumulated format', () => {
|
||||
// Legacy format without phase headers
|
||||
const summary = `## Summary
|
||||
|
||||
This is a simple summary without phase headers.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPhaseSummary', () => {
|
||||
it('should extract specific phase by name (case-insensitive)', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing content.`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'implementation')).toBe('Implementation content.');
|
||||
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Implementation content.');
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content.');
|
||||
expect(extractPhaseSummary(summary, 'testing')).toBe('Testing content.');
|
||||
});
|
||||
|
||||
it('should return null for non-existent phase', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content here.`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'code review')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
expect(extractPhaseSummary('', 'implementation')).toBeNull();
|
||||
expect(extractPhaseSummary(undefined, 'implementation')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImplementationSummary', () => {
|
||||
it('should extract implementation phase from accumulated summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Created auth module.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
All tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Approved.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Created auth module.');
|
||||
expect(result).not.toContain('Testing');
|
||||
expect(result).not.toContain('Code Review');
|
||||
});
|
||||
|
||||
it('should return implementation phase even when not first', () => {
|
||||
const summary = `### Planning
|
||||
|
||||
Plan created.
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Implemented the feature.
|
||||
|
||||
---
|
||||
|
||||
### Review
|
||||
|
||||
Reviewed.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Implemented the feature.');
|
||||
});
|
||||
|
||||
it('should handle phase with "implementation" in name', () => {
|
||||
const summary = `### Feature Implementation
|
||||
|
||||
Built the feature.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Built the feature.');
|
||||
});
|
||||
|
||||
it('should return full summary for non-accumulated format (legacy)', () => {
|
||||
// Non-pipeline features store summary without phase headers
|
||||
const summary = `## Changes
|
||||
- Fixed bug
|
||||
- Added tests`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe(summary);
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
expect(extractImplementationSummary('')).toBeNull();
|
||||
expect(extractImplementationSummary(undefined)).toBeNull();
|
||||
expect(extractImplementationSummary(' \n\n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no implementation phase in accumulated summary', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Tests written.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Approved.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAccumulatedSummary', () => {
|
||||
it('should return true for accumulated multi-phase summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for single phase summary (no separator)', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for legacy non-accumulated format', () => {
|
||||
const summary = `## Summary
|
||||
|
||||
This is a simple summary.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty input', () => {
|
||||
expect(isAccumulatedSummary('')).toBe(false);
|
||||
expect(isAccumulatedSummary(undefined)).toBe(false);
|
||||
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true even for two phases', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content A.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Content B.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptance criteria scenarios', () => {
|
||||
it('AC1: Implementation summary preserved when Testing completes', () => {
|
||||
// Given a task card completes the Implementation phase,
|
||||
// when the Testing phase subsequently completes,
|
||||
// then the Implementation phase summary must remain stored independently
|
||||
const summary = `### Implementation
|
||||
|
||||
- Created auth module
|
||||
- Added user service
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
- 42 tests pass
|
||||
- 98% coverage`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
const testing = extractPhaseSummary(summary, 'testing');
|
||||
|
||||
expect(impl).toBe('- Created auth module\n- Added user service');
|
||||
expect(testing).toBe('- 42 tests pass\n- 98% coverage');
|
||||
expect(impl).not.toContain('Testing');
|
||||
expect(testing).not.toContain('auth module');
|
||||
});
|
||||
|
||||
it('AC4: Implementation Summary tab shows only implementation phase', () => {
|
||||
// Given a task card has completed the Implementation phase
|
||||
// (regardless of how many subsequent phases have run),
|
||||
// when the user opens the "Implementation Summary" tab,
|
||||
// then it must display only the summary produced by the Implementation phase
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation phase output here.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing phase output here.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Code review output here.`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
|
||||
expect(impl).toBe('Implementation phase output here.');
|
||||
expect(impl).not.toContain('Testing');
|
||||
expect(impl).not.toContain('Code Review');
|
||||
});
|
||||
|
||||
it('AC5: Empty state when implementation not started', () => {
|
||||
// Given a task card has not yet started the Implementation phase
|
||||
const summary = `### Planning
|
||||
|
||||
Planning phase complete.`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
|
||||
// Should return null (UI shows "No implementation summary available")
|
||||
expect(impl).toBeNull();
|
||||
});
|
||||
|
||||
it('AC6: Single phase summary displayed correctly', () => {
|
||||
// Given a task card where Implementation was the only completed phase
|
||||
const summary = `### Implementation
|
||||
|
||||
Only implementation was done.`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
|
||||
expect(impl).toBe('Only implementation was done.');
|
||||
});
|
||||
|
||||
it('AC9: Mid-progress shows only completed phases', () => {
|
||||
// Given a task card is mid-progress
|
||||
// (e.g., Implementation and Testing complete, Code Review pending)
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation done.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing done.`;
|
||||
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
|
||||
expect(phases.size).toBe(2);
|
||||
expect(phases.has('implementation')).toBe(true);
|
||||
expect(phases.has('testing')).toBe(true);
|
||||
expect(phases.has('code review')).toBe(false);
|
||||
});
|
||||
|
||||
it('AC10: All phases in chronological order', () => {
|
||||
// Given all phases of a task card are complete
|
||||
const summary = `### Implementation
|
||||
|
||||
First phase content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Second phase content.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Third phase content.`;
|
||||
|
||||
// ParsePhaseSummaries should preserve order
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
const phaseNames = [...phases.keys()];
|
||||
|
||||
expect(phaseNames).toEqual(['implementation', 'testing', 'code review']);
|
||||
});
|
||||
|
||||
it('AC17: Retried phase shows only latest', () => {
|
||||
// Given a phase was retried, when viewing the Summary tab,
|
||||
// only one entry for the retried phase must appear (the latest retry's summary)
|
||||
//
|
||||
// Note: The server-side FeatureStateManager overwrites the phase summary
|
||||
// when the same phase runs again, so we only have one entry per phase name.
|
||||
// This test verifies that the parser correctly handles this.
|
||||
const summary = `### Implementation
|
||||
|
||||
First attempt content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
First test run.
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Retry content - fixed issues.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Retry - all tests now pass.`;
|
||||
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
|
||||
// The parser will have both entries, but Map keeps last value for same key
|
||||
expect(phases.get('implementation')).toBe('Retry content - fixed issues.');
|
||||
expect(phases.get('testing')).toBe('Retry - all tests now pass.');
|
||||
});
|
||||
});
|
||||
});
|
||||
238
apps/server/tests/unit/ui/summary-auto-scroll.test.ts
Normal file
238
apps/server/tests/unit/ui/summary-auto-scroll.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unit tests for the summary auto-scroll detection logic.
|
||||
*
|
||||
* These tests verify the behavior of the scroll detection function used in
|
||||
* AgentOutputModal to determine if auto-scroll should be enabled.
|
||||
*
|
||||
* The logic mirrors the handleSummaryScroll function in:
|
||||
* apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
|
||||
*
|
||||
* Auto-scroll behavior:
|
||||
* - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled
|
||||
* - When user scrolls up to view older content, auto-scroll is disabled
|
||||
* - Scrolling back to bottom re-enables auto-scroll
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Determines if the scroll position is at the bottom of the container.
|
||||
* This is the core logic from handleSummaryScroll in AgentOutputModal.
|
||||
*
|
||||
* @param scrollTop - Current scroll position from top
|
||||
* @param scrollHeight - Total scrollable height
|
||||
* @param clientHeight - Visible height of the container
|
||||
* @param threshold - Distance from bottom to consider "at bottom" (default: 50px)
|
||||
* @returns true if at bottom, false otherwise
|
||||
*/
|
||||
function isScrollAtBottom(
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
threshold = 50
|
||||
): boolean {
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
return distanceFromBottom < threshold;
|
||||
}
|
||||
|
||||
describe('Summary Auto-Scroll Detection Logic', () => {
|
||||
describe('basic scroll position detection', () => {
|
||||
it('should return true when scrolled to exact bottom', () => {
|
||||
// Container: 500px tall, content: 1000px tall
|
||||
// ScrollTop: 500 (scrolled to bottom)
|
||||
const result = isScrollAtBottom(500, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when near bottom (within threshold)', () => {
|
||||
// 49px from bottom - within 50px threshold
|
||||
const result = isScrollAtBottom(451, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when exactly at threshold boundary (49px)', () => {
|
||||
// 49px from bottom
|
||||
const result = isScrollAtBottom(451, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when just outside threshold (51px)', () => {
|
||||
// 51px from bottom - outside 50px threshold
|
||||
const result = isScrollAtBottom(449, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when scrolled to top', () => {
|
||||
const result = isScrollAtBottom(0, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when scrolled to middle', () => {
|
||||
const result = isScrollAtBottom(250, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases with small content', () => {
|
||||
it('should return true when content fits in viewport (no scroll needed)', () => {
|
||||
// Content is smaller than container - no scrolling possible
|
||||
const result = isScrollAtBottom(0, 300, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when content exactly fits viewport', () => {
|
||||
const result = isScrollAtBottom(0, 500, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when content slightly exceeds viewport (within threshold)', () => {
|
||||
// Content: 540px, Viewport: 500px, can scroll 40px
|
||||
// At scroll 0, we're 40px from bottom - within threshold
|
||||
const result = isScrollAtBottom(0, 540, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('large content scenarios', () => {
|
||||
it('should correctly detect bottom in very long content', () => {
|
||||
// Simulate accumulated summary from many pipeline steps
|
||||
// Content: 10000px, Viewport: 500px
|
||||
const result = isScrollAtBottom(9500, 10000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly detect non-bottom in very long content', () => {
|
||||
// User scrolled up to read earlier summaries
|
||||
const result = isScrollAtBottom(5000, 10000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect when user scrolls up from bottom', () => {
|
||||
// Started at bottom (scroll: 9500), then scrolled up 100px
|
||||
const result = isScrollAtBottom(9400, 10000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom threshold values', () => {
|
||||
it('should work with larger threshold (100px)', () => {
|
||||
// 75px from bottom - within 100px threshold
|
||||
const result = isScrollAtBottom(425, 1000, 500, 100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with smaller threshold (10px)', () => {
|
||||
// 15px from bottom - outside 10px threshold
|
||||
const result = isScrollAtBottom(485, 1000, 500, 10);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should work with zero threshold (exact match only)', () => {
|
||||
// At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison
|
||||
// This is an edge case: the implementation uses < not <=
|
||||
const result = isScrollAtBottom(500, 1000, 500, 0);
|
||||
expect(result).toBe(false); // 0 < 0 is false
|
||||
|
||||
// 1px from bottom - also fails
|
||||
const result2 = isScrollAtBottom(499, 1000, 500, 0);
|
||||
expect(result2).toBe(false);
|
||||
|
||||
// For exact match with 0 threshold, we need negative distanceFromBottom
|
||||
// which happens when scrollTop > scrollHeight - clientHeight (overscroll)
|
||||
const result3 = isScrollAtBottom(501, 1000, 500, 0);
|
||||
expect(result3).toBe(true); // -1 < 0 is true
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline summary scrolling scenarios', () => {
|
||||
it('should enable auto-scroll when new content arrives while at bottom', () => {
|
||||
// User is at bottom viewing step 2 summary
|
||||
// Step 3 summary is added, increasing scrollHeight from 1000 to 1500
|
||||
// ScrollTop stays at 950 (was at bottom), but now user needs to scroll
|
||||
|
||||
// Before new content: isScrollAtBottom(950, 1000, 500) = true
|
||||
// After new content: auto-scroll should kick in to scroll to new bottom
|
||||
|
||||
// Simulating the auto-scroll effect setting scrollTop to new bottom
|
||||
const newScrollTop = 1500 - 500; // scrollHeight - clientHeight
|
||||
const result = isScrollAtBottom(newScrollTop, 1500, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not auto-scroll when user is reading earlier summaries', () => {
|
||||
// User scrolled up to read step 1 summary while step 3 is added
|
||||
// scrollHeight increases, but scrollTop stays same
|
||||
// User is now further from bottom
|
||||
|
||||
// User was at scroll position 200 (reading early content)
|
||||
// New content increases scrollHeight from 1000 to 1500
|
||||
// Distance from bottom goes from 300 to 800
|
||||
const result = isScrollAtBottom(200, 1500, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should re-enable auto-scroll when user scrolls back to bottom', () => {
|
||||
// User was reading step 1 (scrollTop: 200)
|
||||
// User scrolls back to bottom to see latest content
|
||||
const result = isScrollAtBottom(1450, 1500, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimal scroll values', () => {
|
||||
it('should handle fractional scroll positions', () => {
|
||||
// Browsers can report fractional scroll values
|
||||
const result = isScrollAtBottom(499.5, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle fractional scroll heights', () => {
|
||||
const result = isScrollAtBottom(450.7, 1000.3, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('negative and invalid inputs', () => {
|
||||
it('should handle negative scrollTop (bounce scroll)', () => {
|
||||
// iOS can report negative scrollTop during bounce
|
||||
const result = isScrollAtBottom(-10, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle zero scrollHeight', () => {
|
||||
// Empty content
|
||||
const result = isScrollAtBottom(0, 0, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle zero clientHeight', () => {
|
||||
// Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000
|
||||
// This is not < threshold, so returns false
|
||||
// This edge case represents a broken/invisible container
|
||||
const result = isScrollAtBottom(0, 1000, 0);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world accumulated summary dimensions', () => {
|
||||
it('should handle typical 3-step pipeline summary dimensions', () => {
|
||||
// Approximate: 3 steps x ~800px each = ~2400px
|
||||
// Viewport: 400px (modal height)
|
||||
const result = isScrollAtBottom(2000, 2400, 400);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large 10-step pipeline summary dimensions', () => {
|
||||
// Approximate: 10 steps x ~800px each = ~8000px
|
||||
// Viewport: 400px
|
||||
const result = isScrollAtBottom(7600, 8000, 400);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect scroll to top of large summary', () => {
|
||||
// User at top of 10-step summary
|
||||
const result = isScrollAtBottom(0, 8000, 400);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
apps/server/tests/unit/ui/summary-normalization.test.ts
Normal file
128
apps/server/tests/unit/ui/summary-normalization.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for summary normalization between UI components and parser functions.
|
||||
*
|
||||
* These tests verify that:
|
||||
* - getFirstNonEmptySummary returns string | null
|
||||
* - parseAllPhaseSummaries and isAccumulatedSummary expect string | undefined
|
||||
* - The normalization (summary ?? undefined) correctly converts null to undefined
|
||||
*
|
||||
* This ensures the UI components properly bridge the type gap between:
|
||||
* - getFirstNonEmptySummary (returns string | null)
|
||||
* - parseAllPhaseSummaries (expects string | undefined)
|
||||
* - isAccumulatedSummary (expects string | undefined)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
|
||||
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
|
||||
|
||||
describe('Summary Normalization', () => {
|
||||
describe('getFirstNonEmptySummary', () => {
|
||||
it('should return the first non-empty string', () => {
|
||||
const result = getFirstNonEmptySummary(null, undefined, 'valid summary', 'another');
|
||||
expect(result).toBe('valid summary');
|
||||
});
|
||||
|
||||
it('should return null when all candidates are empty', () => {
|
||||
const result = getFirstNonEmptySummary(null, undefined, '', ' ');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no candidates provided', () => {
|
||||
const result = getFirstNonEmptySummary();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for all null/undefined candidates', () => {
|
||||
const result = getFirstNonEmptySummary(null, undefined, null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve original string formatting (not trim)', () => {
|
||||
const result = getFirstNonEmptySummary(' summary with spaces ');
|
||||
expect(result).toBe(' summary with spaces ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAllPhaseSummaries with normalized input', () => {
|
||||
it('should handle null converted to undefined via ?? operator', () => {
|
||||
const summary = getFirstNonEmptySummary(null, undefined);
|
||||
// This is the normalization: summary ?? undefined
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
// TypeScript should accept this without error
|
||||
const result = parseAllPhaseSummaries(normalizedSummary);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse accumulated summary when non-null is normalized', () => {
|
||||
const rawSummary =
|
||||
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
|
||||
const summary = getFirstNonEmptySummary(null, rawSummary);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = parseAllPhaseSummaries(normalizedSummary);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[1].phaseName).toBe('Testing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAccumulatedSummary with normalized input', () => {
|
||||
it('should return false for null converted to undefined', () => {
|
||||
const summary = getFirstNonEmptySummary(null, undefined);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = isAccumulatedSummary(normalizedSummary);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for valid accumulated summary after normalization', () => {
|
||||
const rawSummary =
|
||||
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
|
||||
const summary = getFirstNonEmptySummary(rawSummary);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = isAccumulatedSummary(normalizedSummary);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for single-phase summary after normalization', () => {
|
||||
const rawSummary = '### Implementation\n\nDid some work';
|
||||
const summary = getFirstNonEmptySummary(rawSummary);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = isAccumulatedSummary(normalizedSummary);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type safety verification', () => {
|
||||
it('should demonstrate that null must be normalized to undefined', () => {
|
||||
// This test documents the type mismatch that requires normalization
|
||||
const summary: string | null = getFirstNonEmptySummary(null);
|
||||
const normalizedSummary: string | undefined = summary ?? undefined;
|
||||
|
||||
// parseAllPhaseSummaries expects string | undefined, not string | null
|
||||
// The normalization converts null -> undefined, which is compatible
|
||||
const result = parseAllPhaseSummaries(normalizedSummary);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should work with the actual usage pattern from components', () => {
|
||||
// Simulates the actual pattern used in summary-dialog.tsx and agent-output-modal.tsx
|
||||
const featureSummary: string | null | undefined = null;
|
||||
const extractedSummary: string | null | undefined = undefined;
|
||||
|
||||
const rawSummary = getFirstNonEmptySummary(featureSummary, extractedSummary);
|
||||
const normalizedSummary = rawSummary ?? undefined;
|
||||
|
||||
// Both parser functions should work with the normalized value
|
||||
const phases = parseAllPhaseSummaries(normalizedSummary);
|
||||
const hasMultiple = isAccumulatedSummary(normalizedSummary);
|
||||
|
||||
expect(phases).toEqual([]);
|
||||
expect(hasMultiple).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
|
||||
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
|
||||
|
||||
/**
|
||||
* Mirrors summary source priority in agent-info-panel.tsx:
|
||||
* freshFeature.summary > feature.summary > summaryProp > agentInfo.summary
|
||||
*/
|
||||
function getCardEffectiveSummary(params: {
|
||||
freshFeatureSummary?: string | null;
|
||||
featureSummary?: string | null;
|
||||
summaryProp?: string | null;
|
||||
agentInfoSummary?: string | null;
|
||||
}): string | undefined | null {
|
||||
return getFirstNonEmptySummary(
|
||||
params.freshFeatureSummary,
|
||||
params.featureSummary,
|
||||
params.summaryProp,
|
||||
params.agentInfoSummary
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors SummaryDialog raw summary selection in summary-dialog.tsx:
|
||||
* summaryProp > feature.summary > agentInfo.summary
|
||||
*/
|
||||
function getDialogRawSummary(params: {
|
||||
summaryProp?: string | null;
|
||||
featureSummary?: string | null;
|
||||
agentInfoSummary?: string | null;
|
||||
}): string | undefined | null {
|
||||
return getFirstNonEmptySummary(
|
||||
params.summaryProp,
|
||||
params.featureSummary,
|
||||
params.agentInfoSummary
|
||||
);
|
||||
}
|
||||
|
||||
describe('Summary Source Flow Integration', () => {
|
||||
it('uses fresh per-feature summary in card and preserves it through summary dialog', () => {
|
||||
const staleListSummary = '## Old summary from stale list cache';
|
||||
const freshAccumulatedSummary = `### Implementation
|
||||
|
||||
Implemented auth + profile flow.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit tests: 18 passed
|
||||
- Integration tests: 6 passed`;
|
||||
const parsedAgentInfoSummary = 'Fallback summary from parsed agent output';
|
||||
|
||||
const cardEffectiveSummary = getCardEffectiveSummary({
|
||||
freshFeatureSummary: freshAccumulatedSummary,
|
||||
featureSummary: staleListSummary,
|
||||
summaryProp: undefined,
|
||||
agentInfoSummary: parsedAgentInfoSummary,
|
||||
});
|
||||
|
||||
expect(cardEffectiveSummary).toBe(freshAccumulatedSummary);
|
||||
|
||||
const dialogRawSummary = getDialogRawSummary({
|
||||
summaryProp: cardEffectiveSummary,
|
||||
featureSummary: staleListSummary,
|
||||
agentInfoSummary: parsedAgentInfoSummary,
|
||||
});
|
||||
|
||||
expect(dialogRawSummary).toBe(freshAccumulatedSummary);
|
||||
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(true);
|
||||
|
||||
const phases = parseAllPhaseSummaries(dialogRawSummary ?? undefined);
|
||||
expect(phases).toHaveLength(2);
|
||||
expect(phases[0]?.phaseName).toBe('Implementation');
|
||||
expect(phases[1]?.phaseName).toBe('Testing');
|
||||
});
|
||||
|
||||
it('falls back in order when fresher sources are absent', () => {
|
||||
const cardEffectiveSummary = getCardEffectiveSummary({
|
||||
freshFeatureSummary: undefined,
|
||||
featureSummary: '',
|
||||
summaryProp: undefined,
|
||||
agentInfoSummary: 'Agent parsed fallback',
|
||||
});
|
||||
|
||||
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
|
||||
|
||||
const dialogRawSummary = getDialogRawSummary({
|
||||
summaryProp: undefined,
|
||||
featureSummary: undefined,
|
||||
agentInfoSummary: cardEffectiveSummary,
|
||||
});
|
||||
|
||||
expect(dialogRawSummary).toBe('Agent parsed fallback');
|
||||
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats whitespace-only summaries as empty during fallback selection', () => {
|
||||
const cardEffectiveSummary = getCardEffectiveSummary({
|
||||
freshFeatureSummary: ' \n',
|
||||
featureSummary: '\t',
|
||||
summaryProp: ' ',
|
||||
agentInfoSummary: 'Agent parsed fallback',
|
||||
});
|
||||
|
||||
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
|
||||
});
|
||||
});
|
||||
1
apps/ui/.gitignore
vendored
1
apps/ui/.gitignore
vendored
@@ -38,6 +38,7 @@ yarn-error.log*
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/tests/.auth/
|
||||
|
||||
# Electron
|
||||
/release/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.15.0",
|
||||
"version": "1.0.0",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"desktopName": "automaker.desktop",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
@@ -144,6 +145,9 @@
|
||||
"@playwright/test": "1.57.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@tanstack/router-plugin": "1.141.7",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dagre": "0.7.53",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/react": "19.2.7",
|
||||
@@ -156,6 +160,7 @@
|
||||
"electron-builder": "26.0.12",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
@@ -202,6 +207,10 @@
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "public/logo_larger.png",
|
||||
"to": "logo_larger.png"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -261,7 +270,12 @@
|
||||
"maintainer": "webdevcody@gmail.com",
|
||||
"executableName": "automaker",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"synopsis": "AI-powered autonomous development studio"
|
||||
"synopsis": "AI-powered autonomous development studio",
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"Icon": "/opt/Automaker/resources/logo_larger.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rpm": {
|
||||
"depends": [
|
||||
@@ -275,7 +289,8 @@
|
||||
"libuuid"
|
||||
],
|
||||
"compression": "xz",
|
||||
"vendor": "AutoMaker Team"
|
||||
"vendor": "AutoMaker Team",
|
||||
"afterInstall": "scripts/rpm-after-install.sh"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
const port = process.env.TEST_PORT || 3107;
|
||||
|
||||
// PATH that includes common git locations so the E2E server can run git (worktree list, etc.)
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const extraPath =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
|
||||
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
|
||||
].filter(Boolean)
|
||||
: [
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/usr/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
process.env.HOME && `${process.env.HOME}/.local/bin`,
|
||||
].filter(Boolean);
|
||||
const e2eServerPath = [process.env.PATH, ...extraPath].filter(Boolean).join(pathSeparator);
|
||||
const serverPort = process.env.TEST_SERVER_PORT || 3108;
|
||||
// When true, no webServer is started; you must run UI (port 3107) and server (3108) yourself.
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
|
||||
const useExternalBackend = !!process.env.VITE_SERVER_URL;
|
||||
// Only skip backend startup when explicitly requested for E2E runs.
|
||||
// VITE_SERVER_URL may be set in user shells for local dev and should not affect tests.
|
||||
const useExternalBackend = process.env.TEST_USE_EXTERNAL_BACKEND === 'true';
|
||||
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
|
||||
const mockAgent = true;
|
||||
|
||||
// Auth state file written by global setup, reused by all tests to skip per-test login
|
||||
const AUTH_STATE_PATH = path.join(__dirname, 'tests/.auth/storage-state.json');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
// Keep Playwright scoped to E2E specs so Vitest unit files are not executed here.
|
||||
testMatch: '**/*.spec.ts',
|
||||
testIgnore: ['**/unit/**'],
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1, // Run sequentially to avoid auth conflicts with shared server
|
||||
reporter: 'html',
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
// Use multiple workers for parallelism. CI gets 2 workers (constrained resources),
|
||||
// local runs use 8 workers for faster test execution.
|
||||
workers: process.env.CI ? 2 : 8,
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
trace: 'on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
serviceWorkers: 'block',
|
||||
// Reuse auth state from global setup - avoids per-test login overhead
|
||||
storageState: AUTH_STATE_PATH,
|
||||
},
|
||||
// Global setup - authenticate before each test
|
||||
// Global setup - authenticate once and save state for all workers
|
||||
globalSetup: require.resolve('./tests/global-setup.ts'),
|
||||
globalTeardown: require.resolve('./tests/global-teardown.ts'),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
@@ -40,13 +72,15 @@ export default defineConfig({
|
||||
: [
|
||||
{
|
||||
command: `cd ../server && npm run dev:test`,
|
||||
url: `http://localhost:${serverPort}/api/health`,
|
||||
url: `http://127.0.0.1:${serverPort}/api/health`,
|
||||
// Don't reuse existing server to ensure we use the test API key
|
||||
reuseExistingServer: false,
|
||||
timeout: 60000,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
// Ensure server can find git in CI/minimal env (worktree list, etc.)
|
||||
PATH: e2eServerPath,
|
||||
// Enable mock agent in CI to avoid real API calls
|
||||
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
|
||||
// Set a test API key for web mode authentication
|
||||
@@ -59,13 +93,17 @@ export default defineConfig({
|
||||
ALLOWED_ROOT_DIRECTORY: '',
|
||||
// Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true',
|
||||
// Increase Node.js memory limit to prevent OOM during tests
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--max-old-space-size=4096']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
},
|
||||
},
|
||||
]),
|
||||
// Frontend Vite dev server
|
||||
{
|
||||
command: `npm run dev`,
|
||||
url: `http://localhost:${port}`,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
@@ -77,6 +115,11 @@ export default defineConfig({
|
||||
VITE_SKIP_SETUP: 'true',
|
||||
// Always skip electron plugin during tests - prevents duplicate server spawning
|
||||
VITE_SKIP_ELECTRON: 'true',
|
||||
// Clear VITE_SERVER_URL to force the frontend to use the Vite proxy (/api)
|
||||
// instead of calling the backend directly. Direct calls bypass the proxy and
|
||||
// cause cookie domain mismatches (cookies are bound to 127.0.0.1 but
|
||||
// VITE_SERVER_URL typically uses localhost).
|
||||
VITE_SERVER_URL: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -10,7 +10,9 @@ const execAsync = promisify(exec);
|
||||
|
||||
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3108;
|
||||
const UI_PORT = process.env.TEST_PORT || 3107;
|
||||
const USE_EXTERNAL_SERVER = !!process.env.VITE_SERVER_URL;
|
||||
// Match Playwright config semantics: only explicit opt-in should skip backend startup/cleanup.
|
||||
// VITE_SERVER_URL may exist in local shells and should not implicitly affect test behavior.
|
||||
const USE_EXTERNAL_SERVER = process.env.TEST_USE_EXTERNAL_BACKEND === 'true';
|
||||
console.log(`[KillTestServers] SERVER_PORT ${SERVER_PORT}`);
|
||||
console.log(`[KillTestServers] UI_PORT ${UI_PORT}`);
|
||||
async function killProcessOnPort(port) {
|
||||
|
||||
14
apps/ui/scripts/rpm-after-install.sh
Normal file
14
apps/ui/scripts/rpm-after-install.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Set the setuid bit on chrome-sandbox so Electron's sandbox works on systems
|
||||
# where unprivileged user namespaces are restricted (e.g. hardened kernels).
|
||||
# On Fedora/RHEL with standard kernel settings this is a safe no-op.
|
||||
chmod 4755 /opt/Automaker/chrome-sandbox 2>/dev/null || true
|
||||
|
||||
# Refresh the GTK icon cache so GNOME/KDE picks up the newly installed icon
|
||||
# immediately without requiring a logout. The -f flag forces a rebuild even
|
||||
# if the cache is up-to-date; -t suppresses the mtime check warning.
|
||||
gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
|
||||
|
||||
# Rebuild the desktop entry database so the app appears in the app launcher
|
||||
# straight after install.
|
||||
update-desktop-database /usr/share/applications 2>/dev/null || true
|
||||
@@ -18,6 +18,8 @@ const __dirname = path.dirname(__filename);
|
||||
const WORKSPACE_ROOT = path.resolve(__dirname, '../../..');
|
||||
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
|
||||
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
|
||||
const CONTEXT_DIR = path.join(FIXTURE_PATH, '.automaker/context');
|
||||
const CONTEXT_METADATA_PATH = path.join(CONTEXT_DIR, 'context-metadata.json');
|
||||
const SERVER_SETTINGS_PATH = path.join(WORKSPACE_ROOT, 'apps/server/data/settings.json');
|
||||
// Create a shared test workspace directory that will be used as default for project creation
|
||||
const TEST_WORKSPACE_DIR = path.join(os.tmpdir(), 'automaker-e2e-workspace');
|
||||
@@ -145,6 +147,14 @@ function setupFixtures() {
|
||||
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
|
||||
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
|
||||
|
||||
// Create .automaker/context and context-metadata.json (expected by context view / FS read)
|
||||
if (!fs.existsSync(CONTEXT_DIR)) {
|
||||
fs.mkdirSync(CONTEXT_DIR, { recursive: true });
|
||||
console.log(`Created directory: ${CONTEXT_DIR}`);
|
||||
}
|
||||
fs.writeFileSync(CONTEXT_METADATA_PATH, JSON.stringify({ files: {} }, null, 2));
|
||||
console.log(`Created fixture file: ${CONTEXT_METADATA_PATH}`);
|
||||
|
||||
// Reset server settings.json to a clean state for E2E tests
|
||||
const settingsDir = path.dirname(SERVER_SETTINGS_PATH);
|
||||
if (!fs.existsSync(settingsDir)) {
|
||||
|
||||
@@ -312,7 +312,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
|
||||
<SheetHeader className="px-6 pt-6">
|
||||
<SheetHeader
|
||||
className="px-6"
|
||||
style={{
|
||||
paddingTop: 'max(1.5rem, calc(env(safe-area-inset-top, 0px) + 1rem))',
|
||||
}}
|
||||
>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5 text-brand-500" />
|
||||
Board Background Settings
|
||||
|
||||
@@ -177,7 +177,7 @@ export function FileBrowserDialog({
|
||||
onSelect(currentPath);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [currentPath, onSelect, onOpenChange]);
|
||||
}, [currentPath, onSelect, onOpenChange, addRecentFolder]);
|
||||
|
||||
// Handle Command/Ctrl+Enter keyboard shortcut to select current folder
|
||||
useEffect(() => {
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils';
|
||||
import { cn, generateUUID, normalizeModelEntry } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useGitHubPRReviewComments } from '@/hooks/queries';
|
||||
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
|
||||
@@ -45,7 +45,7 @@ import { toast } from 'sonner';
|
||||
import type { PRReviewComment } from '@/lib/electron';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import type { PhaseModelEntry } from '@automaker/types';
|
||||
import { supportsReasoningEffort, normalizeThinkingLevelForModel } from '@automaker/types';
|
||||
import { normalizeThinkingLevelForModel } from '@automaker/types';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults';
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface PRCommentResolutionPRInfo {
|
||||
title: string;
|
||||
/** The branch name (headRefName) associated with this PR, used to assign features to the correct worktree */
|
||||
headRefName?: string;
|
||||
/** The URL of the PR, used to set prUrl on created features */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface PRCommentResolutionDialogProps {
|
||||
@@ -730,14 +732,9 @@ export function PRCommentResolutionDialog({
|
||||
|
||||
const selectedComments = comments.filter((c) => selectedIds.has(c.id));
|
||||
|
||||
// Resolve model settings from the current model entry
|
||||
const selectedModel = resolveModelString(modelEntry.model);
|
||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||
? modelEntry.thinkingLevel || 'none'
|
||||
: 'none';
|
||||
const normalizedReasoning = supportsReasoningEffort(selectedModel)
|
||||
? modelEntry.reasoningEffort || 'none'
|
||||
: 'none';
|
||||
// Resolve and normalize model settings
|
||||
const normalizedEntry = normalizeModelEntry(modelEntry);
|
||||
const selectedModel = resolveModelString(normalizedEntry.model);
|
||||
|
||||
setIsCreating(true);
|
||||
setCreationErrors([]);
|
||||
@@ -753,8 +750,13 @@ export function PRCommentResolutionDialog({
|
||||
steps: [],
|
||||
status: 'backlog',
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
reasoningEffort: normalizedReasoning,
|
||||
thinkingLevel: normalizedEntry.thinkingLevel,
|
||||
reasoningEffort: normalizedEntry.reasoningEffort,
|
||||
providerId: normalizedEntry.providerId,
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
dependencies: [],
|
||||
...(pr.url ? { prUrl: pr.url } : {}),
|
||||
// Associate feature with the PR's branch so it appears on the correct worktree
|
||||
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||
};
|
||||
@@ -779,8 +781,13 @@ export function PRCommentResolutionDialog({
|
||||
steps: [],
|
||||
status: 'backlog',
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
reasoningEffort: normalizedReasoning,
|
||||
thinkingLevel: normalizedEntry.thinkingLevel,
|
||||
reasoningEffort: normalizedEntry.reasoningEffort,
|
||||
providerId: normalizedEntry.providerId,
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
dependencies: [],
|
||||
...(pr.url ? { prUrl: pr.url } : {}),
|
||||
// Associate feature with the PR's branch so it appears on the correct worktree
|
||||
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user