diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3c90fd38..9fbc5375 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -83,6 +83,7 @@ import { createNotificationsRoutes } from './routes/notifications/index.js'; import { getNotificationService } from './services/notification-service.js'; import { createEventHistoryRoutes } from './routes/event-history/index.js'; import { getEventHistoryService } from './services/event-history-service.js'; +import { getTestRunnerService } from './services/test-runner-service.js'; // Load environment variables dotenv.config(); @@ -248,6 +249,10 @@ notificationService.setEventEmitter(events); // Initialize Event History Service const eventHistoryService = getEventHistoryService(); +// Initialize Test Runner Service with event emitter for real-time test output streaming +const testRunnerService = getTestRunnerService(); +testRunnerService.setEventEmitter(events); + // Initialize Event Hook Service for custom event triggers (with history storage) eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader); diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7459ca57..abf9c522 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -42,6 +42,9 @@ import { createStartDevHandler } from './routes/start-dev.js'; import { createStopDevHandler } from './routes/stop-dev.js'; import { createListDevServersHandler } from './routes/list-dev-servers.js'; import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js'; +import { createStartTestsHandler } from './routes/start-tests.js'; +import { createStopTestsHandler } from './routes/stop-tests.js'; +import { createGetTestLogsHandler } from './routes/test-logs.js'; import { createGetInitScriptHandler, createPutInitScriptHandler, @@ -140,6 +143,15 @@ export function createWorktreeRoutes( createGetDevServerLogsHandler() ); + // Test runner routes + router.post( + '/start-tests', + validatePathParams('worktreePath', 'projectPath?'), + createStartTestsHandler(settingsService) + ); + router.post('/stop-tests', createStopTestsHandler()); + router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler()); + // Init script routes router.get('/init-script', createGetInitScriptHandler()); router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); diff --git a/apps/server/src/routes/worktree/routes/start-tests.ts b/apps/server/src/routes/worktree/routes/start-tests.ts new file mode 100644 index 00000000..54837056 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/start-tests.ts @@ -0,0 +1,92 @@ +/** + * POST /start-tests endpoint - Start tests for a worktree + * + * Runs the test command configured in project settings. + * If no testCommand is configured, returns an error. + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getTestRunnerService } from '../../../services/test-runner-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStartTestsHandler(settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body; + + // Validate request body + if (!body || typeof body !== 'object') { + res.status(400).json({ + success: false, + error: 'Request body must be an object', + }); + return; + } + + const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined; + const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined; + const testFile = typeof body.testFile === 'string' ? body.testFile : undefined; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required and must be a string', + }); + return; + } + + // Get project settings to find the test command + // Use projectPath if provided, otherwise use worktreePath + const settingsPath = projectPath || worktreePath; + + if (!settingsService) { + res.status(500).json({ + success: false, + error: 'Settings service not available', + }); + return; + } + + const projectSettings = await settingsService.getProjectSettings(settingsPath); + const testCommand = projectSettings?.testCommand; + + if (!testCommand) { + res.status(400).json({ + success: false, + error: + 'No test command configured. Please configure a test command in Project Settings > Testing Configuration.', + }); + return; + } + + const testRunnerService = getTestRunnerService(); + const result = await testRunnerService.startTests(worktreePath, { + command: testCommand, + testFile, + }); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + sessionId: result.result.sessionId, + worktreePath: result.result.worktreePath, + command: result.result.command, + status: result.result.status, + testFile: result.result.testFile, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to start tests', + }); + } + } catch (error) { + logError(error, 'Start tests failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stop-tests.ts b/apps/server/src/routes/worktree/routes/stop-tests.ts new file mode 100644 index 00000000..48181f24 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stop-tests.ts @@ -0,0 +1,58 @@ +/** + * POST /stop-tests endpoint - Stop a running test session + * + * Stops the test runner process for a specific session, + * cancelling any ongoing tests and freeing up resources. + */ + +import type { Request, Response } from 'express'; +import { getTestRunnerService } from '../../../services/test-runner-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStopTestsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body; + + // Validate request body + if (!body || typeof body !== 'object') { + res.status(400).json({ + success: false, + error: 'Request body must be an object', + }); + return; + } + + const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined; + + if (!sessionId) { + res.status(400).json({ + success: false, + error: 'sessionId is required and must be a string', + }); + return; + } + + const testRunnerService = getTestRunnerService(); + const result = await testRunnerService.stopTests(sessionId); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + sessionId: result.result.sessionId, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to stop tests', + }); + } + } catch (error) { + logError(error, 'Stop tests failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/test-logs.ts b/apps/server/src/routes/worktree/routes/test-logs.ts new file mode 100644 index 00000000..724730cc --- /dev/null +++ b/apps/server/src/routes/worktree/routes/test-logs.ts @@ -0,0 +1,160 @@ +/** + * GET /test-logs endpoint - Get buffered logs for a test runner session + * + * Returns the scrollback buffer containing historical log output for a test run. + * Used by clients to populate the log panel on initial connection + * before subscribing to real-time updates via WebSocket. + * + * Query parameters: + * - worktreePath: Path to the worktree (optional if sessionId provided) + * - sessionId: Specific test session ID (optional, uses active session if not provided) + */ + +import type { Request, Response } from 'express'; +import { getTestRunnerService } from '../../../services/test-runner-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface SessionInfo { + sessionId: string; + worktreePath?: string; + command?: string; + testFile?: string; + exitCode?: number | null; +} + +interface OutputResult { + sessionId: string; + status: string; + output: string; + startedAt: string; + finishedAt?: string | null; +} + +function buildLogsResponse(session: SessionInfo, output: OutputResult) { + return { + success: true, + result: { + sessionId: session.sessionId, + worktreePath: session.worktreePath, + command: session.command, + status: output.status, + testFile: session.testFile, + logs: output.output, + startedAt: output.startedAt, + finishedAt: output.finishedAt, + exitCode: session.exitCode ?? null, + }, + }; +} + +export function createGetTestLogsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, sessionId } = req.query as { + worktreePath?: string; + sessionId?: string; + }; + + const testRunnerService = getTestRunnerService(); + + // If sessionId is provided, get logs for that specific session + if (sessionId) { + const result = testRunnerService.getSessionOutput(sessionId); + + if (result.success && result.result) { + const session = testRunnerService.getSession(sessionId); + res.json( + buildLogsResponse( + { + sessionId: result.result.sessionId, + worktreePath: session?.worktreePath, + command: session?.command, + testFile: session?.testFile, + exitCode: session?.exitCode, + }, + result.result + ) + ); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get test logs', + }); + } + return; + } + + // If worktreePath is provided, get logs for the active session + if (worktreePath) { + const activeSession = testRunnerService.getActiveSession(worktreePath); + + if (activeSession) { + const result = testRunnerService.getSessionOutput(activeSession.id); + + if (result.success && result.result) { + res.json( + buildLogsResponse( + { + sessionId: activeSession.id, + worktreePath: activeSession.worktreePath, + command: activeSession.command, + testFile: activeSession.testFile, + exitCode: activeSession.exitCode, + }, + result.result + ) + ); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get test logs', + }); + } + } else { + // No active session - check for most recent session for this worktree + const sessions = testRunnerService.listSessions(worktreePath); + if (sessions.result.sessions.length > 0) { + // Get the most recent session (list is not sorted, so find it) + const mostRecent = sessions.result.sessions.reduce((latest, current) => { + const latestTime = new Date(latest.startedAt).getTime(); + const currentTime = new Date(current.startedAt).getTime(); + return currentTime > latestTime ? current : latest; + }); + + const result = testRunnerService.getSessionOutput(mostRecent.sessionId); + if (result.success && result.result) { + res.json( + buildLogsResponse( + { + sessionId: mostRecent.sessionId, + worktreePath: mostRecent.worktreePath, + command: mostRecent.command, + testFile: mostRecent.testFile, + exitCode: mostRecent.exitCode, + }, + result.result + ) + ); + return; + } + } + + res.status(404).json({ + success: false, + error: 'No test sessions found for this worktree', + }); + } + return; + } + + // Neither sessionId nor worktreePath provided + res.status(400).json({ + success: false, + error: 'Either worktreePath or sessionId query parameter is required', + }); + } catch (error) { + logError(error, 'Get test logs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/services/test-runner-service.ts b/apps/server/src/services/test-runner-service.ts new file mode 100644 index 00000000..d55d7be6 --- /dev/null +++ b/apps/server/src/services/test-runner-service.ts @@ -0,0 +1,682 @@ +/** + * Test Runner Service + * + * Manages test execution processes for git worktrees. + * Runs user-configured test commands with output streaming. + * + * Features: + * - Process management with graceful shutdown + * - Output buffering and throttling for WebSocket streaming + * - Support for running all tests or specific files + * - Cross-platform process cleanup (Windows/Unix) + */ + +import { spawn, execSync, type ChildProcess } from 'child_process'; +import * as secureFs from '../lib/secure-fs.js'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; + +const logger = createLogger('TestRunnerService'); + +// Maximum scrollback buffer size (characters) +const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per test run + +// Throttle output to prevent overwhelming WebSocket under heavy load +// Note: Too aggressive throttling (< 50ms) can cause memory issues and UI crashes +// due to rapid React state updates and string concatenation overhead +const OUTPUT_THROTTLE_MS = 100; // ~10fps - balances responsiveness with stability +const OUTPUT_BATCH_SIZE = 8192; // Larger batch size to reduce event frequency + +/** + * Status of a test run + */ +export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error'; + +/** + * Information about an active test run session + */ +export interface TestRunSession { + /** Unique identifier for this test run */ + id: string; + /** Path to the worktree where tests are running */ + worktreePath: string; + /** The command being run */ + command: string; + /** The spawned child process */ + process: ChildProcess | null; + /** When the test run started */ + startedAt: Date; + /** When the test run finished (if completed) */ + finishedAt: Date | null; + /** Current status of the test run */ + status: TestRunStatus; + /** Exit code from the process (if completed) */ + exitCode: number | null; + /** Specific test file being run (optional) */ + testFile?: string; + /** Scrollback buffer for log history (replay on reconnect) */ + scrollbackBuffer: string; + /** Pending output to be flushed to subscribers */ + outputBuffer: string; + /** Throttle timer for batching output */ + flushTimeout: NodeJS.Timeout | null; + /** Flag to indicate session is stopping (prevents output after stop) */ + stopping: boolean; +} + +/** + * Result of a test run operation + */ +export interface TestRunResult { + success: boolean; + result?: { + sessionId: string; + worktreePath: string; + command: string; + status: TestRunStatus; + testFile?: string; + message: string; + }; + error?: string; +} + +/** + * Test Runner Service class + * Manages test execution processes across worktrees + */ +class TestRunnerService { + private sessions: Map = new Map(); + private emitter: EventEmitter | null = null; + + /** + * Set the event emitter for streaming log events + * Called during service initialization with the global event emitter + */ + setEventEmitter(emitter: EventEmitter): void { + this.emitter = emitter; + } + + /** + * Helper to check if a file exists using secureFs + */ + private async fileExists(filePath: string): Promise { + try { + await secureFs.access(filePath); + return true; + } catch { + return false; + } + } + + /** + * Append data to scrollback buffer with size limit enforcement + * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE + */ + private appendToScrollback(session: TestRunSession, data: string): void { + session.scrollbackBuffer += data; + if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + } + + /** + * Flush buffered output to WebSocket subscribers + * Sends batched output to prevent overwhelming clients under heavy load + */ + private flushOutput(session: TestRunSession): void { + // Skip flush if session is stopping or buffer is empty + if (session.stopping || session.outputBuffer.length === 0) { + session.flushTimeout = null; + return; + } + + let dataToSend = session.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + // Send in batches if buffer is large + dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS); + } else { + session.outputBuffer = ''; + session.flushTimeout = null; + } + + // Emit output event for WebSocket streaming + if (this.emitter) { + this.emitter.emit('test-runner:output', { + sessionId: session.id, + worktreePath: session.worktreePath, + content: dataToSend, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Handle incoming stdout/stderr data from test process + * Buffers data for scrollback replay and schedules throttled emission + */ + private handleProcessOutput(session: TestRunSession, data: Buffer): void { + // Skip output if session is stopping + if (session.stopping) { + return; + } + + const content = data.toString(); + + // Append to scrollback buffer for replay on reconnect + this.appendToScrollback(session, content); + + // Buffer output for throttled live delivery + session.outputBuffer += content; + + // Schedule flush if not already scheduled + if (!session.flushTimeout) { + session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS); + } + + // Also log for debugging (existing behavior) + logger.debug(`[${session.id}] ${content.trim()}`); + } + + /** + * Kill any process running (platform-specific cleanup) + */ + private killProcessTree(pid: number): void { + try { + if (process.platform === 'win32') { + // Windows: use taskkill to kill process tree + execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' }); + } else { + // Unix: kill the process group + try { + process.kill(-pid, 'SIGTERM'); + } catch { + // Fallback to killing just the process + process.kill(pid, 'SIGTERM'); + } + } + } catch (error) { + logger.debug(`Error killing process ${pid}:`, error); + } + } + + /** + * Generate a unique session ID + */ + private generateSessionId(): string { + return `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + } + + /** + * Sanitize a test file path to prevent command injection + * Allows only safe characters for file paths + */ + private sanitizeTestFile(testFile: string): string { + // Remove any shell metacharacters and normalize path + // Allow only alphanumeric, dots, slashes, hyphens, underscores, colons (for Windows paths) + return testFile.replace(/[^a-zA-Z0-9.\\/_\-:]/g, ''); + } + + /** + * Start tests in a worktree using the provided command + * + * @param worktreePath - Path to the worktree where tests should run + * @param options - Configuration for the test run + * @returns TestRunResult with session info or error + */ + async startTests( + worktreePath: string, + options: { + command: string; + testFile?: string; + } + ): Promise { + const { command, testFile } = options; + + // Check if already running + const existingSession = this.getActiveSession(worktreePath); + if (existingSession) { + return { + success: false, + error: `Tests are already running for this worktree (session: ${existingSession.id})`, + }; + } + + // Verify the worktree exists + if (!(await this.fileExists(worktreePath))) { + return { + success: false, + error: `Worktree path does not exist: ${worktreePath}`, + }; + } + + if (!command) { + return { + success: false, + error: 'No test command provided', + }; + } + + // Build the final command (append test file if specified) + let finalCommand = command; + if (testFile) { + // Sanitize test file path to prevent command injection + const sanitizedFile = this.sanitizeTestFile(testFile); + // Append the test file to the command + // Most test runners support: command -- file or command file + finalCommand = `${command} -- ${sanitizedFile}`; + } + + // Parse command into cmd and args (shell execution) + // We use shell: true to support complex commands like "npm run test:server" + logger.info(`Starting tests in ${worktreePath}`); + logger.info(`Command: ${finalCommand}`); + + // Create session + const sessionId = this.generateSessionId(); + const session: TestRunSession = { + id: sessionId, + worktreePath, + command: finalCommand, + process: null, + startedAt: new Date(), + finishedAt: null, + status: 'pending', + exitCode: null, + testFile, + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + }; + + // Spawn the test process using shell + const env = { + ...process.env, + FORCE_COLOR: '1', + COLORTERM: 'truecolor', + TERM: 'xterm-256color', + CI: 'true', // Helps some test runners format output better + }; + + const testProcess = spawn(finalCommand, [], { + cwd: worktreePath, + env, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', // Use process groups on Unix for cleanup + }); + + session.process = testProcess; + session.status = 'running'; + + // Track if process failed early + const status = { error: null as string | null, exited: false }; + + // Helper to clean up resources and emit events + const cleanupAndFinish = ( + exitCode: number | null, + finalStatus: TestRunStatus, + errorMessage?: string + ) => { + session.finishedAt = new Date(); + session.exitCode = exitCode; + session.status = finalStatus; + + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } + + // Flush any remaining output + if (session.outputBuffer.length > 0 && this.emitter && !session.stopping) { + this.emitter.emit('test-runner:output', { + sessionId: session.id, + worktreePath: session.worktreePath, + content: session.outputBuffer, + timestamp: new Date().toISOString(), + }); + session.outputBuffer = ''; + } + + // Emit completed event + if (this.emitter && !session.stopping) { + this.emitter.emit('test-runner:completed', { + sessionId: session.id, + worktreePath: session.worktreePath, + command: session.command, + status: finalStatus, + exitCode, + error: errorMessage, + duration: session.finishedAt.getTime() - session.startedAt.getTime(), + timestamp: new Date().toISOString(), + }); + } + }; + + // Capture stdout + if (testProcess.stdout) { + testProcess.stdout.on('data', (data: Buffer) => { + this.handleProcessOutput(session, data); + }); + } + + // Capture stderr + if (testProcess.stderr) { + testProcess.stderr.on('data', (data: Buffer) => { + this.handleProcessOutput(session, data); + }); + } + + testProcess.on('error', (error) => { + logger.error(`Process error for ${sessionId}:`, error); + status.error = error.message; + cleanupAndFinish(null, 'error', error.message); + }); + + testProcess.on('exit', (code) => { + logger.info(`Test process for ${worktreePath} exited with code ${code}`); + status.exited = true; + + // Determine final status based on exit code + let finalStatus: TestRunStatus; + if (session.stopping) { + finalStatus = 'cancelled'; + } else if (code === 0) { + finalStatus = 'passed'; + } else { + finalStatus = 'failed'; + } + + cleanupAndFinish(code, finalStatus); + }); + + // Store session + this.sessions.set(sessionId, session); + + // Wait a moment to see if the process fails immediately + await new Promise((resolve) => setTimeout(resolve, 200)); + + if (status.error) { + return { + success: false, + error: `Failed to start tests: ${status.error}`, + }; + } + + if (status.exited) { + // Process already exited - check if it was immediate failure + const exitedSession = this.sessions.get(sessionId); + if (exitedSession && exitedSession.status === 'error') { + return { + success: false, + error: `Test process exited immediately. Check output for details.`, + }; + } + } + + // Emit started event + if (this.emitter) { + this.emitter.emit('test-runner:started', { + sessionId, + worktreePath, + command: finalCommand, + testFile, + timestamp: new Date().toISOString(), + }); + } + + return { + success: true, + result: { + sessionId, + worktreePath, + command: finalCommand, + status: 'running', + testFile, + message: `Tests started: ${finalCommand}`, + }, + }; + } + + /** + * Stop a running test session + * + * @param sessionId - The ID of the test session to stop + * @returns Result with success status and message + */ + async stopTests(sessionId: string): Promise<{ + success: boolean; + result?: { sessionId: string; message: string }; + error?: string; + }> { + const session = this.sessions.get(sessionId); + + if (!session) { + return { + success: false, + error: `Test session not found: ${sessionId}`, + }; + } + + if (session.status !== 'running') { + return { + success: true, + result: { + sessionId, + message: `Tests already finished (status: ${session.status})`, + }, + }; + } + + logger.info(`Cancelling test session ${sessionId}`); + + // Mark as stopping to prevent further output events + session.stopping = true; + + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } + + // Kill the process + if (session.process && !session.process.killed && session.process.pid) { + this.killProcessTree(session.process.pid); + } + + session.status = 'cancelled'; + session.finishedAt = new Date(); + + // Emit cancelled event + if (this.emitter) { + this.emitter.emit('test-runner:completed', { + sessionId, + worktreePath: session.worktreePath, + command: session.command, + status: 'cancelled', + exitCode: null, + duration: session.finishedAt.getTime() - session.startedAt.getTime(), + timestamp: new Date().toISOString(), + }); + } + + return { + success: true, + result: { + sessionId, + message: 'Test run cancelled', + }, + }; + } + + /** + * Get the active test session for a worktree + */ + getActiveSession(worktreePath: string): TestRunSession | undefined { + for (const session of this.sessions.values()) { + if (session.worktreePath === worktreePath && session.status === 'running') { + return session; + } + } + return undefined; + } + + /** + * Get a test session by ID + */ + getSession(sessionId: string): TestRunSession | undefined { + return this.sessions.get(sessionId); + } + + /** + * Get buffered output for a test session + */ + getSessionOutput(sessionId: string): { + success: boolean; + result?: { + sessionId: string; + output: string; + status: TestRunStatus; + startedAt: string; + finishedAt: string | null; + }; + error?: string; + } { + const session = this.sessions.get(sessionId); + + if (!session) { + return { + success: false, + error: `Test session not found: ${sessionId}`, + }; + } + + return { + success: true, + result: { + sessionId, + output: session.scrollbackBuffer, + status: session.status, + startedAt: session.startedAt.toISOString(), + finishedAt: session.finishedAt?.toISOString() || null, + }, + }; + } + + /** + * List all test sessions (optionally filter by worktree) + */ + listSessions(worktreePath?: string): { + success: boolean; + result: { + sessions: Array<{ + sessionId: string; + worktreePath: string; + command: string; + status: TestRunStatus; + testFile?: string; + startedAt: string; + finishedAt: string | null; + exitCode: number | null; + }>; + }; + } { + let sessions = Array.from(this.sessions.values()); + + if (worktreePath) { + sessions = sessions.filter((s) => s.worktreePath === worktreePath); + } + + return { + success: true, + result: { + sessions: sessions.map((s) => ({ + sessionId: s.id, + worktreePath: s.worktreePath, + command: s.command, + status: s.status, + testFile: s.testFile, + startedAt: s.startedAt.toISOString(), + finishedAt: s.finishedAt?.toISOString() || null, + exitCode: s.exitCode, + })), + }, + }; + } + + /** + * Check if a worktree has an active test run + */ + isRunning(worktreePath: string): boolean { + return this.getActiveSession(worktreePath) !== undefined; + } + + /** + * Clean up old completed sessions (keep only recent ones) + */ + cleanupOldSessions(maxAgeMs: number = 30 * 60 * 1000): void { + const now = Date.now(); + for (const [sessionId, session] of this.sessions.entries()) { + if (session.status !== 'running' && session.finishedAt) { + if (now - session.finishedAt.getTime() > maxAgeMs) { + this.sessions.delete(sessionId); + logger.debug(`Cleaned up old test session: ${sessionId}`); + } + } + } + } + + /** + * Cancel all running test sessions (for cleanup) + */ + async cancelAll(): Promise { + logger.info(`Cancelling all ${this.sessions.size} test sessions`); + + for (const session of this.sessions.values()) { + if (session.status === 'running') { + await this.stopTests(session.id); + } + } + } + + /** + * Cleanup service resources + */ + async cleanup(): Promise { + await this.cancelAll(); + this.sessions.clear(); + } +} + +// Singleton instance +let testRunnerServiceInstance: TestRunnerService | null = null; + +export function getTestRunnerService(): TestRunnerService { + if (!testRunnerServiceInstance) { + testRunnerServiceInstance = new TestRunnerService(); + } + return testRunnerServiceInstance; +} + +// Cleanup on process exit +process.on('SIGTERM', () => { + if (testRunnerServiceInstance) { + testRunnerServiceInstance.cleanup().catch((err) => { + logger.error('Cleanup failed on SIGTERM:', err); + }); + } +}); + +process.on('SIGINT', () => { + if (testRunnerServiceInstance) { + testRunnerServiceInstance.cleanup().catch((err) => { + logger.error('Cleanup failed on SIGINT:', err); + }); + } +}); + +// Export the class for testing purposes +export { TestRunnerService }; diff --git a/apps/ui/src/components/ui/test-logs-panel.tsx b/apps/ui/src/components/ui/test-logs-panel.tsx new file mode 100644 index 00000000..119557c2 --- /dev/null +++ b/apps/ui/src/components/ui/test-logs-panel.tsx @@ -0,0 +1,426 @@ +'use client'; + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { + Terminal, + ArrowDown, + Square, + RefreshCw, + AlertCircle, + Clock, + GitBranch, + CheckCircle2, + XCircle, + FlaskConical, +} from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer'; +import { useTestLogs } from '@/hooks/use-test-logs'; +import { useIsMobile } from '@/hooks/use-media-query'; +import type { TestRunStatus } from '@/types/electron'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface TestLogsPanelProps { + /** Whether the panel is open */ + open: boolean; + /** Callback when the panel is closed */ + onClose: () => void; + /** Path to the worktree to show test logs for */ + worktreePath: string | null; + /** Branch name for display */ + branch?: string; + /** Specific session ID to fetch logs for (optional) */ + sessionId?: string; + /** Callback to stop the running tests */ + onStopTests?: () => void; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get status indicator based on test run status + */ +function getStatusIndicator(status: TestRunStatus | null): { + text: string; + className: string; + icon?: React.ReactNode; +} { + switch (status) { + case 'running': + return { + text: 'Running', + className: 'bg-blue-500/10 text-blue-500', + icon: , + }; + case 'pending': + return { + text: 'Pending', + className: 'bg-amber-500/10 text-amber-500', + icon: , + }; + case 'passed': + return { + text: 'Passed', + className: 'bg-green-500/10 text-green-500', + icon: , + }; + case 'failed': + return { + text: 'Failed', + className: 'bg-red-500/10 text-red-500', + icon: , + }; + case 'cancelled': + return { + text: 'Cancelled', + className: 'bg-yellow-500/10 text-yellow-500', + icon: , + }; + case 'error': + return { + text: 'Error', + className: 'bg-red-500/10 text-red-500', + icon: , + }; + default: + return { + text: 'Idle', + className: 'bg-muted text-muted-foreground', + }; + } +} + +/** + * Format duration in milliseconds to human-readable string + */ +function formatDuration(ms: number | null): string | null { + if (ms === null) return null; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}m ${seconds}s`; +} + +/** + * Format timestamp to localized time string + */ +function formatTime(timestamp: string | null): string | null { + if (!timestamp) return null; + try { + const date = new Date(timestamp); + return date.toLocaleTimeString(); + } catch { + return null; + } +} + +// ============================================================================ +// Inner Content Component +// ============================================================================ + +interface TestLogsPanelContentProps { + worktreePath: string | null; + branch?: string; + sessionId?: string; + onStopTests?: () => void; +} + +function TestLogsPanelContent({ + worktreePath, + branch, + sessionId, + onStopTests, +}: TestLogsPanelContentProps) { + const xtermRef = useRef(null); + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); + const lastLogsLengthRef = useRef(0); + const lastSessionIdRef = useRef(null); + + const { + logs, + isLoading, + error, + status, + sessionId: currentSessionId, + command, + testFile, + startedAt, + exitCode, + duration, + isRunning, + fetchLogs, + } = useTestLogs({ + worktreePath, + sessionId, + autoSubscribe: true, + }); + + // Write logs to xterm when they change + useEffect(() => { + if (!xtermRef.current || !logs) return; + + // If session changed, reset the terminal and write all content + if (lastSessionIdRef.current !== currentSessionId) { + lastSessionIdRef.current = currentSessionId; + lastLogsLengthRef.current = 0; + xtermRef.current.write(logs); + lastLogsLengthRef.current = logs.length; + return; + } + + // If logs got shorter (e.g., cleared), rewrite all + if (logs.length < lastLogsLengthRef.current) { + xtermRef.current.write(logs); + lastLogsLengthRef.current = logs.length; + return; + } + + // Append only the new content + if (logs.length > lastLogsLengthRef.current) { + const newContent = logs.slice(lastLogsLengthRef.current); + xtermRef.current.append(newContent); + lastLogsLengthRef.current = logs.length; + } + }, [logs, currentSessionId]); + + // Reset auto-scroll when session changes + useEffect(() => { + if (currentSessionId !== lastSessionIdRef.current) { + setAutoScrollEnabled(true); + lastLogsLengthRef.current = 0; + } + }, [currentSessionId]); + + // Scroll to bottom handler + const scrollToBottom = useCallback(() => { + xtermRef.current?.scrollToBottom(); + setAutoScrollEnabled(true); + }, []); + + const statusIndicator = getStatusIndicator(status); + const formattedStartTime = formatTime(startedAt); + const formattedDuration = formatDuration(duration); + const lineCount = logs ? logs.split('\n').length : 0; + + return ( + <> + {/* Header */} + +
+ + + Test Runner + {status && ( + + {statusIndicator.icon} + {statusIndicator.text} + + )} + {formattedDuration && !isRunning && ( + {formattedDuration} + )} + +
+ {isRunning && onStopTests && ( + + )} + +
+
+ + {/* Info bar */} +
+ {branch && ( + + + {branch} + + )} + {command && ( + + Command + {command} + + )} + {testFile && ( + + File + {testFile} + + )} + {formattedStartTime && ( + + + {formattedStartTime} + + )} +
+
+ + {/* Error displays */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Log content area */} +
+ {isLoading && !logs ? ( +
+ + Loading logs... +
+ ) : !logs && !isRunning && !status ? ( +
+ +

No test run active

+

Start a test run to see logs here

+
+ ) : isRunning && !logs ? ( +
+ +

Waiting for output...

+

Logs will appear as tests generate output

+
+ ) : ( + setAutoScrollEnabled(false)} + onScrollToBottom={() => setAutoScrollEnabled(true)} + /> + )} +
+ + {/* Footer status bar */} +
+
+ {lineCount > 0 ? `${lineCount} lines` : 'No output'} + {exitCode !== null && ( + + Exit: {exitCode} + + )} +
+ {!autoScrollEnabled && logs && ( + + )} + {autoScrollEnabled && logs && ( + + + Auto-scroll + + )} +
+ + ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +/** + * Panel component for displaying test runner logs with ANSI color rendering + * and real-time streaming support. + * + * Features: + * - Real-time log streaming via WebSocket + * - Full ANSI color code rendering via xterm.js + * - Auto-scroll to bottom (can be paused by scrolling up) + * - Test status indicators (pending, running, passed, failed, etc.) + * - Dialog on desktop, Sheet on mobile + * - Quick actions (stop tests, refresh logs) + */ +export function TestLogsPanel({ + open, + onClose, + worktreePath, + branch, + sessionId, + onStopTests, +}: TestLogsPanelProps) { + const isMobile = useIsMobile(); + + if (!worktreePath) return null; + + // Mobile: use Sheet (bottom drawer) + if (isMobile) { + return ( + !isOpen && onClose()}> + + + Test Logs + + + + + ); + } + + // Desktop: use Dialog + return ( + !isOpen && onClose()}> + + + + + ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 8ba682d9..2a87d3e1 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -33,10 +33,11 @@ import { SplitSquareHorizontal, Undo2, Zap, + FlaskConical, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; -import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; +import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types'; import { TooltipWrapper } from './tooltip-wrapper'; import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors'; import { @@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps { standalone?: boolean; /** Whether auto mode is running for this worktree */ isAutoModeRunning?: boolean; + /** Whether a test command is configured in project settings */ + hasTestCommand?: boolean; + /** Whether tests are being started for this worktree */ + isStartingTests?: boolean; + /** Whether tests are currently running for this worktree */ + isTestRunning?: boolean; + /** Active test session info for this worktree */ + testSessionInfo?: TestSessionInfo; onOpenChange: (open: boolean) => void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; @@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps { onRunInitScript: (worktree: WorktreeInfo) => void; onToggleAutoMode?: (worktree: WorktreeInfo) => void; onMerge: (worktree: WorktreeInfo) => void; + /** Start running tests for this worktree */ + onStartTests?: (worktree: WorktreeInfo) => void; + /** Stop running tests for this worktree */ + onStopTests?: (worktree: WorktreeInfo) => void; + /** View test logs for this worktree */ + onViewTestLogs?: (worktree: WorktreeInfo) => void; hasInitScript: boolean; } @@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({ gitRepoStatus, standalone = false, isAutoModeRunning = false, + hasTestCommand = false, + isStartingTests = false, + isTestRunning = false, + testSessionInfo, onOpenChange, onPull, onPush, @@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({ onRunInitScript, onToggleAutoMode, onMerge, + onStartTests, + onStopTests, + onViewTestLogs, hasInitScript, }: WorktreeActionsDropdownProps) { // Get available editors for the "Open In" submenu @@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({ )} + {/* Test Runner section - only show when test command is configured */} + {hasTestCommand && onStartTests && ( + <> + {isTestRunning ? ( + <> + + + Tests Running + + {onViewTestLogs && ( + onViewTestLogs(worktree)} className="text-xs"> + + View Test Logs + + )} + {onStopTests && ( + onStopTests(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Stop Tests + + )} + + + ) : ( + <> + onStartTests(worktree)} + disabled={isStartingTests} + className="text-xs" + > + + {isStartingTests ? 'Starting Tests...' : 'Run Tests'} + + {onViewTestLogs && testSessionInfo && ( + onViewTestLogs(worktree)} className="text-xs"> + + View Last Test Results + {testSessionInfo.status === 'passed' && ( + + passed + + )} + {testSessionInfo.status === 'failed' && ( + + failed + + )} + + )} + + + )} + + )} {/* Auto Mode toggle */} {onToggleAutoMode && ( <> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index d8a57ced..25a79f96 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useDroppable } from '@dnd-kit/core'; -import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; +import type { + WorktreeInfo, + BranchInfo, + DevServerInfo, + PRInfo, + GitRepoStatus, + TestSessionInfo, +} from '../types'; import { BranchSwitchDropdown } from './branch-switch-dropdown'; import { WorktreeActionsDropdown } from './worktree-actions-dropdown'; @@ -33,6 +40,12 @@ interface WorktreeTabProps { gitRepoStatus: GitRepoStatus; /** Whether auto mode is running for this worktree */ isAutoModeRunning?: boolean; + /** Whether tests are being started for this worktree */ + isStartingTests?: boolean; + /** Whether tests are currently running for this worktree */ + isTestRunning?: boolean; + /** Active test session info for this worktree */ + testSessionInfo?: TestSessionInfo; onSelectWorktree: (worktree: WorktreeInfo) => void; onBranchDropdownOpenChange: (open: boolean) => void; onActionsDropdownOpenChange: (open: boolean) => void; @@ -59,7 +72,15 @@ interface WorktreeTabProps { onViewDevServerLogs: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void; onToggleAutoMode?: (worktree: WorktreeInfo) => void; + /** Start running tests for this worktree */ + onStartTests?: (worktree: WorktreeInfo) => void; + /** Stop running tests for this worktree */ + onStopTests?: (worktree: WorktreeInfo) => void; + /** View test logs for this worktree */ + onViewTestLogs?: (worktree: WorktreeInfo) => void; hasInitScript: boolean; + /** Whether a test command is configured in project settings */ + hasTestCommand?: boolean; } export function WorktreeTab({ @@ -85,6 +106,9 @@ export function WorktreeTab({ hasRemoteBranch, gitRepoStatus, isAutoModeRunning = false, + isStartingTests = false, + isTestRunning = false, + testSessionInfo, onSelectWorktree, onBranchDropdownOpenChange, onActionsDropdownOpenChange, @@ -111,7 +135,11 @@ export function WorktreeTab({ onViewDevServerLogs, onRunInitScript, onToggleAutoMode, + onStartTests, + onStopTests, + onViewTestLogs, hasInitScript, + hasTestCommand = false, }: WorktreeTabProps) { // Make the worktree tab a drop target for feature cards const { setNodeRef, isOver } = useDroppable({ @@ -395,6 +423,10 @@ export function WorktreeTab({ devServerInfo={devServerInfo} gitRepoStatus={gitRepoStatus} isAutoModeRunning={isAutoModeRunning} + hasTestCommand={hasTestCommand} + isStartingTests={isStartingTests} + isTestRunning={isTestRunning} + testSessionInfo={testSessionInfo} onOpenChange={onActionsDropdownOpenChange} onPull={onPull} onPush={onPush} @@ -416,6 +448,9 @@ export function WorktreeTab({ onViewDevServerLogs={onViewDevServerLogs} onRunInitScript={onRunInitScript} onToggleAutoMode={onToggleAutoMode} + onStartTests={onStartTests} + onStopTests={onStopTests} + onViewTestLogs={onViewTestLogs} hasInitScript={hasInitScript} /> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index 4ccb3634..0ea7e772 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -30,6 +30,19 @@ export interface DevServerInfo { url: string; } +export interface TestSessionInfo { + sessionId: string; + worktreePath: string; + /** The test command being run (from project settings) */ + command: string; + status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled'; + testFile?: string; + startedAt: string; + finishedAt?: string; + exitCode?: number | null; + duration?: number; +} + export interface FeatureInfo { id: string; branchName?: string; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index cb645ea6..40f10e85 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { getHttpApiClient } from '@/lib/http-api-client'; import { useIsMobile } from '@/hooks/use-media-query'; -import { useWorktreeInitScript } from '@/hooks/queries'; -import type { WorktreePanelProps, WorktreeInfo } from './types'; +import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries'; +import { useTestRunnerEvents } from '@/hooks/use-test-runners'; +import { useTestRunnersStore } from '@/store/test-runners-store'; +import type { + TestRunnerStartedEvent, + TestRunnerOutputEvent, + TestRunnerCompletedEvent, +} from '@/types/electron'; +import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types'; import { useWorktrees, useDevServers, @@ -25,6 +32,7 @@ import { import { useAppStore } from '@/store/app-store'; import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { TestLogsPanel } from '@/components/ui/test-logs-panel'; import { Undo2 } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; @@ -161,6 +169,194 @@ export function WorktreePanel({ const { data: initScriptData } = useWorktreeInitScript(projectPath); const hasInitScript = initScriptData?.exists ?? false; + // Check if test command is configured in project settings + const { data: projectSettings } = useProjectSettings(projectPath); + const hasTestCommand = !!projectSettings?.testCommand; + + // Test runner state management + // Use the test runners store to get global state for all worktrees + const testRunnersStore = useTestRunnersStore(); + const [isStartingTests, setIsStartingTests] = useState(false); + + // Subscribe to test runner events to update store state in real-time + // This ensures the UI updates when tests start, output is received, or tests complete + useTestRunnerEvents( + // onStarted - a new test run has begun + useCallback( + (event: TestRunnerStartedEvent) => { + testRunnersStore.startSession({ + sessionId: event.sessionId, + worktreePath: event.worktreePath, + command: event.command, + status: 'running', + testFile: event.testFile, + startedAt: event.timestamp, + }); + }, + [testRunnersStore] + ), + // onOutput - test output received + useCallback( + (event: TestRunnerOutputEvent) => { + testRunnersStore.appendOutput(event.sessionId, event.content); + }, + [testRunnersStore] + ), + // onCompleted - test run finished + useCallback( + (event: TestRunnerCompletedEvent) => { + testRunnersStore.completeSession( + event.sessionId, + event.status, + event.exitCode, + event.duration + ); + // Show toast notification for test completion + const statusEmoji = + event.status === 'passed' ? '✅' : event.status === 'failed' ? '❌' : 'âšī¸'; + const statusText = + event.status === 'passed' ? 'passed' : event.status === 'failed' ? 'failed' : 'stopped'; + toast(`${statusEmoji} Tests ${statusText}`, { + description: `Exit code: ${event.exitCode ?? 'N/A'}`, + duration: 4000, + }); + }, + [testRunnersStore] + ) + ); + + // Test logs panel state + const [testLogsPanelOpen, setTestLogsPanelOpen] = useState(false); + const [testLogsPanelWorktree, setTestLogsPanelWorktree] = useState(null); + + // Helper to check if tests are running for a specific worktree + const isTestRunningForWorktree = useCallback( + (worktree: WorktreeInfo): boolean => { + return testRunnersStore.isWorktreeRunning(worktree.path); + }, + [testRunnersStore] + ); + + // Helper to get test session info for a specific worktree + const getTestSessionInfo = useCallback( + (worktree: WorktreeInfo): TestSessionInfo | undefined => { + const session = testRunnersStore.getActiveSession(worktree.path); + if (!session) { + // Check for completed sessions to show last result + const allSessions = Object.values(testRunnersStore.sessions).filter( + (s) => s.worktreePath === worktree.path + ); + const lastSession = allSessions.sort( + (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime() + )[0]; + if (lastSession) { + return { + sessionId: lastSession.sessionId, + worktreePath: lastSession.worktreePath, + command: lastSession.command, + status: lastSession.status as TestSessionInfo['status'], + testFile: lastSession.testFile, + startedAt: lastSession.startedAt, + finishedAt: lastSession.finishedAt, + exitCode: lastSession.exitCode, + duration: lastSession.duration, + }; + } + return undefined; + } + return { + sessionId: session.sessionId, + worktreePath: session.worktreePath, + command: session.command, + status: session.status as TestSessionInfo['status'], + testFile: session.testFile, + startedAt: session.startedAt, + finishedAt: session.finishedAt, + exitCode: session.exitCode, + duration: session.duration, + }; + }, + [testRunnersStore] + ); + + // Handler to start tests for a worktree + const handleStartTests = useCallback( + async (worktree: WorktreeInfo) => { + setIsStartingTests(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.startTests) { + toast.error('Test runner API not available'); + return; + } + + const result = await api.worktree.startTests(worktree.path, { projectPath }); + if (result.success) { + toast.success('Tests started', { + description: `Running tests in ${worktree.branch}`, + }); + } else { + toast.error('Failed to start tests', { + description: result.error || 'Unknown error', + }); + } + } catch (error) { + toast.error('Failed to start tests', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsStartingTests(false); + } + }, + [projectPath] + ); + + // Handler to stop tests for a worktree + const handleStopTests = useCallback( + async (worktree: WorktreeInfo) => { + try { + const session = testRunnersStore.getActiveSession(worktree.path); + if (!session) { + toast.error('No active test session to stop'); + return; + } + + const api = getElectronAPI(); + if (!api?.worktree?.stopTests) { + toast.error('Test runner API not available'); + return; + } + + const result = await api.worktree.stopTests(session.sessionId); + if (result.success) { + toast.success('Tests stopped', { + description: `Stopped tests in ${worktree.branch}`, + }); + } else { + toast.error('Failed to stop tests', { + description: result.error || 'Unknown error', + }); + } + } catch (error) { + toast.error('Failed to stop tests', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [testRunnersStore] + ); + + // Handler to view test logs for a worktree + const handleViewTestLogs = useCallback((worktree: WorktreeInfo) => { + setTestLogsPanelWorktree(worktree); + setTestLogsPanelOpen(true); + }, []); + + // Handler to close test logs panel + const handleCloseTestLogsPanel = useCallback(() => { + setTestLogsPanelOpen(false); + }, []); + // View changes dialog state const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false); const [viewChangesWorktree, setViewChangesWorktree] = useState(null); @@ -392,6 +588,10 @@ export function WorktreePanel({ devServerInfo={getDevServerInfo(selectedWorktree)} gitRepoStatus={gitRepoStatus} isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)} + hasTestCommand={hasTestCommand} + isStartingTests={isStartingTests} + isTestRunning={isTestRunningForWorktree(selectedWorktree)} + testSessionInfo={getTestSessionInfo(selectedWorktree)} onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)} onPull={handlePull} onPush={handlePush} @@ -413,6 +613,9 @@ export function WorktreePanel({ onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} onToggleAutoMode={handleToggleAutoMode} + onStartTests={handleStartTests} + onStopTests={handleStopTests} + onViewTestLogs={handleViewTestLogs} hasInitScript={hasInitScript} /> )} @@ -494,6 +697,17 @@ export function WorktreePanel({ onMerged={handleMerged} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} /> + + {/* Test Logs Panel */} + handleStopTests(testLogsPanelWorktree) : undefined + } + /> ); } @@ -530,6 +744,9 @@ export function WorktreePanel({ hasRemoteBranch={hasRemoteBranch} gitRepoStatus={gitRepoStatus} isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)} + isStartingTests={isStartingTests} + isTestRunning={isTestRunningForWorktree(mainWorktree)} + testSessionInfo={getTestSessionInfo(mainWorktree)} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)} @@ -556,7 +773,11 @@ export function WorktreePanel({ onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} onToggleAutoMode={handleToggleAutoMode} + onStartTests={handleStartTests} + onStopTests={handleStopTests} + onViewTestLogs={handleViewTestLogs} hasInitScript={hasInitScript} + hasTestCommand={hasTestCommand} /> )} @@ -596,6 +817,9 @@ export function WorktreePanel({ hasRemoteBranch={hasRemoteBranch} gitRepoStatus={gitRepoStatus} isAutoModeRunning={isAutoModeRunningForWorktree(worktree)} + isStartingTests={isStartingTests} + isTestRunning={isTestRunningForWorktree(worktree)} + testSessionInfo={getTestSessionInfo(worktree)} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)} @@ -622,7 +846,11 @@ export function WorktreePanel({ onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} onToggleAutoMode={handleToggleAutoMode} + onStartTests={handleStartTests} + onStopTests={handleStopTests} + onViewTestLogs={handleViewTestLogs} hasInitScript={hasInitScript} + hasTestCommand={hasTestCommand} /> ); })} @@ -703,6 +931,17 @@ export function WorktreePanel({ onMerged={handleMerged} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} /> + + {/* Test Logs Panel */} + handleStopTests(testLogsPanelWorktree) : undefined + } + /> ); } diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts index e29564d1..6dceea37 100644 --- a/apps/ui/src/components/views/project-settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { User, GitBranch, Palette, AlertTriangle, Workflow } from 'lucide-react'; +import { User, GitBranch, Palette, AlertTriangle, Workflow, FlaskConical } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; export interface ProjectNavigationItem { @@ -11,6 +11,7 @@ export interface ProjectNavigationItem { export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [ { id: 'identity', label: 'Identity', icon: User }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, + { id: 'testing', label: 'Testing', icon: FlaskConical }, { id: 'theme', label: 'Theme', icon: Palette }, { id: 'claude', label: 'Models', icon: Workflow }, { id: 'danger', label: 'Danger Zone', icon: AlertTriangle }, diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts index 89cb87bc..8245991f 100644 --- a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts +++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts @@ -1,6 +1,12 @@ import { useState, useCallback } from 'react'; -export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger'; +export type ProjectSettingsViewId = + | 'identity' + | 'theme' + | 'worktrees' + | 'testing' + | 'claude' + | 'danger'; interface UseProjectSettingsViewOptions { initialView?: ProjectSettingsViewId; diff --git a/apps/ui/src/components/views/project-settings-view/index.ts b/apps/ui/src/components/views/project-settings-view/index.ts index bc16ffaf..1e70ea79 100644 --- a/apps/ui/src/components/views/project-settings-view/index.ts +++ b/apps/ui/src/components/views/project-settings-view/index.ts @@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view'; export { ProjectIdentitySection } from './project-identity-section'; export { ProjectThemeSection } from './project-theme-section'; export { WorktreePreferencesSection } from './worktree-preferences-section'; +export { TestingSection } from './testing-section'; export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks'; export { ProjectSettingsNavigation } from './components/project-settings-navigation'; diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx index 75548f66..fb668999 100644 --- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { ProjectIdentitySection } from './project-identity-section'; import { ProjectThemeSection } from './project-theme-section'; import { WorktreePreferencesSection } from './worktree-preferences-section'; +import { TestingSection } from './testing-section'; import { ProjectModelsSection } from './project-models-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog'; @@ -85,6 +86,8 @@ export function ProjectSettingsView() { return ; case 'worktrees': return ; + case 'testing': + return ; case 'claude': return ; case 'danger': diff --git a/apps/ui/src/components/views/project-settings-view/testing-section.tsx b/apps/ui/src/components/views/project-settings-view/testing-section.tsx new file mode 100644 index 00000000..c457145f --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/testing-section.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { FlaskConical, Save, RotateCcw, Info } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +import type { Project } from '@/lib/electron'; + +interface TestingSectionProps { + project: Project; +} + +export function TestingSection({ project }: TestingSectionProps) { + const [testCommand, setTestCommand] = useState(''); + const [originalTestCommand, setOriginalTestCommand] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // Check if there are unsaved changes + const hasChanges = testCommand !== originalTestCommand; + + // Load project settings when project changes + useEffect(() => { + let isCancelled = false; + const currentPath = project.path; + + const loadProjectSettings = async () => { + setIsLoading(true); + try { + const httpClient = getHttpApiClient(); + const response = await httpClient.settings.getProject(currentPath); + + // Avoid updating state if component unmounted or project changed + if (isCancelled) return; + + if (response.success && response.settings) { + const command = response.settings.testCommand || ''; + setTestCommand(command); + setOriginalTestCommand(command); + } + } catch (error) { + if (!isCancelled) { + console.error('Failed to load project settings:', error); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + loadProjectSettings(); + + return () => { + isCancelled = true; + }; + }, [project.path]); + + // Save test command + const handleSave = useCallback(async () => { + setIsSaving(true); + try { + const httpClient = getHttpApiClient(); + const normalizedCommand = testCommand.trim(); + const response = await httpClient.settings.updateProject(project.path, { + testCommand: normalizedCommand || undefined, + }); + + if (response.success) { + setTestCommand(normalizedCommand); + setOriginalTestCommand(normalizedCommand); + toast.success('Test command saved'); + } else { + toast.error('Failed to save test command', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to save test command:', error); + toast.error('Failed to save test command'); + } finally { + setIsSaving(false); + } + }, [project.path, testCommand]); + + // Reset to original value + const handleReset = useCallback(() => { + setTestCommand(originalTestCommand); + }, [originalTestCommand]); + + // Use a preset command + const handleUsePreset = useCallback((command: string) => { + setTestCommand(command); + }, []); + + return ( +
+
+
+
+ +
+

+ Testing Configuration +

+
+

+ Configure how tests are run for this project. +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {/* Test Command Input */} +
+
+ + {hasChanges && ( + (unsaved changes) + )} +
+ setTestCommand(e.target.value)} + placeholder="e.g., npm test, yarn test, pytest, go test ./..." + className="font-mono text-sm" + data-testid="test-command-input" + /> +

+ The command to run tests for this project. If not specified, the test runner will + auto-detect based on your project structure (package.json, Cargo.toml, go.mod, + etc.). +

+
+ + {/* Auto-detection Info */} +
+ +
+

Auto-detection

+

+ When no custom command is set, the test runner automatically detects and uses the + appropriate test framework based on your project files (Vitest, Jest, Pytest, + Cargo, Go Test, etc.). +

+
+
+ + {/* Quick Presets */} +
+ +
+ {[ + { label: 'npm test', command: 'npm test' }, + { label: 'yarn test', command: 'yarn test' }, + { label: 'pnpm test', command: 'pnpm test' }, + { label: 'bun test', command: 'bun test' }, + { label: 'pytest', command: 'pytest' }, + { label: 'cargo test', command: 'cargo test' }, + { label: 'go test', command: 'go test ./...' }, + ].map((preset) => ( + + ))} +
+

+ Click a preset to use it as your test command. +

+
+ + {/* Action Buttons */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts index 8a354b3d..6fc584c8 100644 --- a/apps/ui/src/hooks/index.ts +++ b/apps/ui/src/hooks/index.ts @@ -8,4 +8,18 @@ export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './ export { useResponsiveKanban } from './use-responsive-kanban'; export { useScrollTracking } from './use-scroll-tracking'; export { useSettingsMigration } from './use-settings-migration'; +export { + useTestRunners, + useTestRunnerEvents, + type StartTestOptions, + type StartTestResult, + type StopTestResult, + type TestSession, +} from './use-test-runners'; +export { + useTestLogs, + useTestLogEvents, + type TestLogState, + type UseTestLogsOptions, +} from './use-test-logs'; export { useWindowState } from './use-window-state'; diff --git a/apps/ui/src/hooks/use-test-logs.ts b/apps/ui/src/hooks/use-test-logs.ts new file mode 100644 index 00000000..596d7895 --- /dev/null +++ b/apps/ui/src/hooks/use-test-logs.ts @@ -0,0 +1,396 @@ +/** + * useTestLogs - Hook for test log streaming and retrieval + * + * This hook provides a focused interface for: + * - Fetching initial buffered test logs + * - Subscribing to real-time log streaming + * - Managing log state for display components + * + * Unlike useTestRunners, this hook focuses solely on log retrieval + * and streaming, making it ideal for log display components. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getElectronAPI } from '@/lib/electron'; +import { pathsEqual } from '@/lib/utils'; +import type { + TestRunStatus, + TestRunnerStartedEvent, + TestRunnerOutputEvent, + TestRunnerCompletedEvent, +} from '@/types/electron'; + +const logger = createLogger('TestLogs'); + +// ============================================================================ +// Types +// ============================================================================ + +/** + * State for test log management + */ +export interface TestLogState { + /** The accumulated log content */ + logs: string; + /** Whether initial logs are being fetched */ + isLoading: boolean; + /** Error message if fetching logs failed */ + error: string | null; + /** Current status of the test run */ + status: TestRunStatus | null; + /** Session ID of the current test run */ + sessionId: string | null; + /** The test command being run (from project settings) */ + command: string | null; + /** Specific test file being run (if applicable) */ + testFile: string | null; + /** Timestamp when the test run started */ + startedAt: string | null; + /** Timestamp when the test run finished (if completed) */ + finishedAt: string | null; + /** Exit code (if test run completed) */ + exitCode: number | null; + /** Duration in milliseconds (if completed) */ + duration: number | null; +} + +/** + * Options for the useTestLogs hook + */ +export interface UseTestLogsOptions { + /** Path to the worktree to monitor logs for */ + worktreePath: string | null; + /** Specific session ID to fetch logs for (optional - will get active/recent if not provided) */ + sessionId?: string; + /** Whether to automatically subscribe to log events (default: true) */ + autoSubscribe?: boolean; +} + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState: TestLogState = { + logs: '', + isLoading: false, + error: null, + status: null, + sessionId: null, + command: null, + testFile: null, + startedAt: null, + finishedAt: null, + exitCode: null, + duration: null, +}; + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Hook to subscribe to test log events and manage log state. + * + * This hook: + * 1. Fetches initial buffered logs from the server + * 2. Subscribes to WebSocket events for real-time log streaming + * 3. Handles test runner started/output/completed events + * 4. Provides log state for rendering in a panel + * + * @example + * ```tsx + * const { logs, status, isLoading, isRunning } = useTestLogs({ + * worktreePath: '/path/to/worktree' + * }); + * + * return ( + *
+ * {isLoading && } + * {isRunning && Running} + *
{logs}
+ *
+ * ); + * ``` + */ +export function useTestLogs({ + worktreePath, + sessionId: targetSessionId, + autoSubscribe = true, +}: UseTestLogsOptions) { + const [state, setState] = useState(initialState); + + // Keep track of whether we've fetched initial logs + const hasFetchedInitialLogs = useRef(false); + + // Track the current session ID for filtering events + const currentSessionId = useRef(targetSessionId ?? null); + + // Guard against stale fetch results when switching worktrees/sessions + const fetchSeq = useRef(0); + + /** + * Derived state: whether tests are currently running + */ + const isRunning = state.status === 'running' || state.status === 'pending'; + + /** + * Fetch buffered logs from the server + */ + const fetchLogs = useCallback(async () => { + if (!worktreePath && !targetSessionId) return; + + // Increment sequence to guard against stale responses + const seq = ++fetchSeq.current; + + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const api = getElectronAPI(); + if (!api?.worktree?.getTestLogs) { + // Check if this request is still current + if (seq !== fetchSeq.current) return; + setState((prev) => ({ + ...prev, + isLoading: false, + error: 'Test logs API not available', + })); + return; + } + + const result = await api.worktree.getTestLogs(worktreePath ?? undefined, targetSessionId); + + // Check if this request is still current (prevent stale updates) + if (seq !== fetchSeq.current) return; + + if (result.success && result.result) { + const { sessionId, command, status, testFile, logs, startedAt, finishedAt, exitCode } = + result.result; + + // Update current session ID for event filtering + currentSessionId.current = sessionId; + + setState((prev) => ({ + ...prev, + logs, + isLoading: false, + error: null, + status, + sessionId, + command, + testFile: testFile ?? null, + startedAt, + finishedAt, + exitCode, + duration: null, // Not provided by getTestLogs + })); + hasFetchedInitialLogs.current = true; + } else { + // No active session - this is not necessarily an error + setState((prev) => ({ + ...prev, + isLoading: false, + error: result.error || null, + })); + } + } catch (error) { + // Check if this request is still current + if (seq !== fetchSeq.current) return; + logger.error('Failed to fetch test logs:', error); + setState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch logs', + })); + } + }, [worktreePath, targetSessionId]); + + /** + * Clear logs and reset state + */ + const clearLogs = useCallback(() => { + setState(initialState); + hasFetchedInitialLogs.current = false; + currentSessionId.current = targetSessionId ?? null; + }, [targetSessionId]); + + /** + * Append content to logs + */ + const appendLogs = useCallback((content: string) => { + setState((prev) => ({ + ...prev, + logs: prev.logs + content, + })); + }, []); + + // Fetch initial logs when worktreePath or sessionId changes + useEffect(() => { + if ((worktreePath || targetSessionId) && autoSubscribe) { + hasFetchedInitialLogs.current = false; + fetchLogs(); + } else { + clearLogs(); + } + }, [worktreePath, targetSessionId, autoSubscribe, fetchLogs, clearLogs]); + + // Subscribe to WebSocket events + useEffect(() => { + if (!autoSubscribe) return; + if (!worktreePath && !targetSessionId) return; + + const api = getElectronAPI(); + if (!api?.worktree?.onTestRunnerEvent) { + logger.warn('Test runner event subscription not available'); + return; + } + + const unsubscribe = api.worktree.onTestRunnerEvent((event) => { + // Filter events based on worktree path or session ID + const eventWorktreePath = event.payload.worktreePath; + const eventSessionId = event.payload.sessionId; + + // If we have a specific session ID target, only accept events for that session + if (targetSessionId && eventSessionId !== targetSessionId) { + return; + } + + // If we have a worktree path, filter by that + if (worktreePath && !pathsEqual(eventWorktreePath, worktreePath)) { + return; + } + + switch (event.type) { + case 'test-runner:started': { + const payload = event.payload as TestRunnerStartedEvent; + logger.info('Test run started:', payload); + + // Update current session ID for future event filtering + currentSessionId.current = payload.sessionId; + + setState((prev) => ({ + ...prev, + status: 'running', + sessionId: payload.sessionId, + command: payload.command, + testFile: payload.testFile ?? null, + startedAt: payload.timestamp, + finishedAt: null, + exitCode: null, + duration: null, + // Clear logs on new test run start + logs: '', + error: null, + })); + hasFetchedInitialLogs.current = false; + break; + } + + case 'test-runner:output': { + const payload = event.payload as TestRunnerOutputEvent; + + // Only append if this is for our current session + if (currentSessionId.current && payload.sessionId !== currentSessionId.current) { + return; + } + + // Append the new output to existing logs + if (payload.content) { + appendLogs(payload.content); + } + break; + } + + case 'test-runner:completed': { + const payload = event.payload as TestRunnerCompletedEvent; + logger.info('Test run completed:', payload); + + // Only update if this is for our current session + if (currentSessionId.current && payload.sessionId !== currentSessionId.current) { + return; + } + + setState((prev) => ({ + ...prev, + status: payload.status, + finishedAt: payload.timestamp, + exitCode: payload.exitCode, + duration: payload.duration, + })); + break; + } + } + }); + + return unsubscribe; + }, [worktreePath, targetSessionId, autoSubscribe, appendLogs]); + + return { + // State + ...state, + + // Derived state + /** Whether tests are currently running */ + isRunning, + + // Actions + /** Fetch/refresh logs from the server */ + fetchLogs, + /** Clear logs and reset state */ + clearLogs, + /** Manually append content to logs */ + appendLogs, + }; +} + +/** + * Hook for subscribing to test log output events globally (across all sessions) + * + * Useful for notification systems or global log monitoring. + * + * @example + * ```tsx + * useTestLogEvents({ + * onOutput: (sessionId, content) => { + * console.log(`[${sessionId}] ${content}`); + * }, + * onCompleted: (sessionId, status) => { + * toast(`Tests ${status}!`); + * }, + * }); + * ``` + */ +export function useTestLogEvents(handlers: { + onStarted?: (event: TestRunnerStartedEvent) => void; + onOutput?: (event: TestRunnerOutputEvent) => void; + onCompleted?: (event: TestRunnerCompletedEvent) => void; +}) { + const { onStarted, onOutput, onCompleted } = handlers; + + useEffect(() => { + const api = getElectronAPI(); + if (!api?.worktree?.onTestRunnerEvent) { + logger.warn('Test runner event subscription not available'); + return; + } + + const unsubscribe = api.worktree.onTestRunnerEvent((event) => { + switch (event.type) { + case 'test-runner:started': + onStarted?.(event.payload as TestRunnerStartedEvent); + break; + case 'test-runner:output': + onOutput?.(event.payload as TestRunnerOutputEvent); + break; + case 'test-runner:completed': + onCompleted?.(event.payload as TestRunnerCompletedEvent); + break; + } + }); + + return unsubscribe; + }, [onStarted, onOutput, onCompleted]); +} + +// Re-export types for convenience +export type { TestRunStatus }; diff --git a/apps/ui/src/hooks/use-test-runners.ts b/apps/ui/src/hooks/use-test-runners.ts new file mode 100644 index 00000000..9b93937e --- /dev/null +++ b/apps/ui/src/hooks/use-test-runners.ts @@ -0,0 +1,393 @@ +/** + * useTestRunners - Hook for test runner lifecycle management + * + * This hook provides a complete interface for: + * - Starting and stopping test runs + * - Subscribing to test runner events (started, output, completed) + * - Managing test session state per worktree + * - Fetching existing test logs + */ + +import { useEffect, useCallback, useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { createLogger } from '@automaker/utils/logger'; +import { getElectronAPI } from '@/lib/electron'; +import { useTestRunnersStore, type TestSession } from '@/store/test-runners-store'; +import type { + TestRunStatus, + TestRunnerStartedEvent, + TestRunnerOutputEvent, + TestRunnerCompletedEvent, +} from '@/types/electron'; + +const logger = createLogger('TestRunners'); + +/** + * Options for starting a test run + */ +export interface StartTestOptions { + /** Project path to get test command from settings */ + projectPath?: string; + /** Specific test file to run (runs all tests if not provided) */ + testFile?: string; +} + +/** + * Result from starting a test run + */ +export interface StartTestResult { + success: boolean; + sessionId?: string; + error?: string; +} + +/** + * Result from stopping a test run + */ +export interface StopTestResult { + success: boolean; + error?: string; +} + +/** + * Hook for managing test runners with full lifecycle support + * + * @param worktreePath - The worktree path to scope the hook to (optional for global event handling) + * @returns Test runner state and actions + */ +export function useTestRunners(worktreePath?: string) { + // Get store state and actions + const { + sessions, + activeSessionByWorktree, + isLoading, + error, + startSession, + appendOutput, + completeSession, + getActiveSession, + getSession, + isWorktreeRunning, + removeSession, + clearWorktreeSessions, + setLoading, + setError, + } = useTestRunnersStore( + useShallow((state) => ({ + sessions: state.sessions, + activeSessionByWorktree: state.activeSessionByWorktree, + isLoading: state.isLoading, + error: state.error, + startSession: state.startSession, + appendOutput: state.appendOutput, + completeSession: state.completeSession, + getActiveSession: state.getActiveSession, + getSession: state.getSession, + isWorktreeRunning: state.isWorktreeRunning, + removeSession: state.removeSession, + clearWorktreeSessions: state.clearWorktreeSessions, + setLoading: state.setLoading, + setError: state.setError, + })) + ); + + // Derived state for the current worktree + const activeSession = useMemo(() => { + if (!worktreePath) return null; + return getActiveSession(worktreePath); + }, [worktreePath, getActiveSession, activeSessionByWorktree]); + + const isRunning = useMemo(() => { + if (!worktreePath) return false; + return isWorktreeRunning(worktreePath); + }, [worktreePath, isWorktreeRunning, activeSessionByWorktree, sessions]); + + // Get all sessions for the current worktree + const worktreeSessions = useMemo(() => { + if (!worktreePath) return []; + return Object.values(sessions).filter((s) => s.worktreePath === worktreePath); + }, [worktreePath, sessions]); + + // Subscribe to test runner events + useEffect(() => { + const api = getElectronAPI(); + if (!api?.worktree?.onTestRunnerEvent) { + logger.warn('Test runner event subscription not available'); + return; + } + + const unsubscribe = api.worktree.onTestRunnerEvent((event) => { + // If worktreePath is specified, only handle events for that worktree + if (worktreePath && event.payload.worktreePath !== worktreePath) { + return; + } + + switch (event.type) { + case 'test-runner:started': { + const payload = event.payload as TestRunnerStartedEvent; + logger.info(`Test run started: ${payload.sessionId} in ${payload.worktreePath}`); + + startSession({ + sessionId: payload.sessionId, + worktreePath: payload.worktreePath, + command: payload.command, + status: 'running', + testFile: payload.testFile, + startedAt: payload.timestamp, + }); + break; + } + + case 'test-runner:output': { + const payload = event.payload as TestRunnerOutputEvent; + appendOutput(payload.sessionId, payload.content); + break; + } + + case 'test-runner:completed': { + const payload = event.payload as TestRunnerCompletedEvent; + logger.info( + `Test run completed: ${payload.sessionId} with status ${payload.status} (exit code: ${payload.exitCode})` + ); + + completeSession(payload.sessionId, payload.status, payload.exitCode, payload.duration); + break; + } + } + }); + + return unsubscribe; + }, [worktreePath, startSession, appendOutput, completeSession]); + + // Load existing test logs on mount (if worktreePath is provided) + useEffect(() => { + if (!worktreePath) return; + + const loadExistingLogs = async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.getTestLogs) return; + + setLoading(true); + setError(null); + + const result = await api.worktree.getTestLogs(worktreePath); + + if (result.success && result.result) { + const { sessionId, command, status, testFile, logs, startedAt, finishedAt, exitCode } = + result.result; + + // Only add if we don't already have this session + const existingSession = getSession(sessionId); + if (!existingSession) { + startSession({ + sessionId, + worktreePath, + command, + status, + testFile, + startedAt, + finishedAt: finishedAt || undefined, + exitCode: exitCode ?? undefined, + }); + + // Add existing logs + if (logs) { + appendOutput(sessionId, logs); + } + } + } + } catch (err) { + logger.error('Error loading test logs:', err); + setError(err instanceof Error ? err.message : 'Failed to load test logs'); + } finally { + setLoading(false); + } + }; + + loadExistingLogs(); + }, [worktreePath, setLoading, setError, getSession, startSession, appendOutput]); + + // Start a test run + const start = useCallback( + async (options?: StartTestOptions): Promise => { + if (!worktreePath) { + return { success: false, error: 'No worktree path provided' }; + } + + try { + const api = getElectronAPI(); + if (!api?.worktree?.startTests) { + return { success: false, error: 'Test runner API not available' }; + } + + logger.info(`Starting tests in ${worktreePath}`, options); + + const result = await api.worktree.startTests(worktreePath, { + projectPath: options?.projectPath, + testFile: options?.testFile, + }); + + if (!result.success) { + logger.error('Failed to start tests:', result.error); + return { success: false, error: result.error }; + } + + logger.info(`Tests started with session: ${result.result?.sessionId}`); + return { + success: true, + sessionId: result.result?.sessionId, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error starting tests'; + logger.error('Error starting tests:', err); + return { success: false, error: errorMessage }; + } + }, + [worktreePath] + ); + + // Stop a test run + const stop = useCallback( + async (sessionId?: string): Promise => { + // Use provided sessionId or get the active session for this worktree + const targetSessionId = sessionId || (worktreePath && activeSession?.sessionId); + + if (!targetSessionId) { + return { success: false, error: 'No active test session to stop' }; + } + + try { + const api = getElectronAPI(); + if (!api?.worktree?.stopTests) { + return { success: false, error: 'Test runner API not available' }; + } + + logger.info(`Stopping test session: ${targetSessionId}`); + + const result = await api.worktree.stopTests(targetSessionId); + + if (!result.success) { + logger.error('Failed to stop tests:', result.error); + return { success: false, error: result.error }; + } + + logger.info('Tests stopped successfully'); + return { success: true }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error stopping tests'; + logger.error('Error stopping tests:', err); + return { success: false, error: errorMessage }; + } + }, + [worktreePath, activeSession] + ); + + // Refresh logs for the current session + const refreshLogs = useCallback( + async (sessionId?: string): Promise<{ success: boolean; logs?: string; error?: string }> => { + const targetSessionId = sessionId || (worktreePath && activeSession?.sessionId); + + if (!targetSessionId && !worktreePath) { + return { success: false, error: 'No session or worktree to refresh' }; + } + + try { + const api = getElectronAPI(); + if (!api?.worktree?.getTestLogs) { + return { success: false, error: 'Test logs API not available' }; + } + + const result = await api.worktree.getTestLogs(worktreePath, targetSessionId); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { + success: true, + logs: result.result?.logs, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error fetching logs'; + return { success: false, error: errorMessage }; + } + }, + [worktreePath, activeSession] + ); + + // Clear session history for the current worktree + const clearHistory = useCallback(() => { + if (worktreePath) { + clearWorktreeSessions(worktreePath); + } + }, [worktreePath, clearWorktreeSessions]); + + return { + // State + /** The currently active test session for this worktree */ + activeSession, + /** Whether tests are currently running in this worktree */ + isRunning, + /** All test sessions for this worktree (including completed) */ + sessions: worktreeSessions, + /** Loading state */ + isLoading, + /** Error state */ + error, + + // Actions + /** Start a test run */ + start, + /** Stop a test run */ + stop, + /** Refresh logs for a session */ + refreshLogs, + /** Clear session history for this worktree */ + clearHistory, + + // Lower-level access (for advanced use cases) + /** Get a specific session by ID */ + getSession, + /** Remove a specific session */ + removeSession, + }; +} + +/** + * Hook for subscribing to test runner events globally (across all worktrees) + * + * Useful for global status displays or notifications + */ +export function useTestRunnerEvents( + onStarted?: (event: TestRunnerStartedEvent) => void, + onOutput?: (event: TestRunnerOutputEvent) => void, + onCompleted?: (event: TestRunnerCompletedEvent) => void +) { + useEffect(() => { + const api = getElectronAPI(); + if (!api?.worktree?.onTestRunnerEvent) { + logger.warn('Test runner event subscription not available'); + return; + } + + const unsubscribe = api.worktree.onTestRunnerEvent((event) => { + switch (event.type) { + case 'test-runner:started': + onStarted?.(event.payload as TestRunnerStartedEvent); + break; + case 'test-runner:output': + onOutput?.(event.payload as TestRunnerOutputEvent); + break; + case 'test-runner:completed': + onCompleted?.(event.payload as TestRunnerCompletedEvent); + break; + } + }); + + return unsubscribe; + }, [onStarted, onOutput, onCompleted]); +} + +// Re-export types for convenience +export type { TestSession, TestRunStatus }; diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 50a8179b..f3f8939b 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -2063,6 +2063,52 @@ function createMockWorktreeAPI(): WorktreeAPI { }, }; }, + + // Test runner methods + startTests: async ( + worktreePath: string, + options?: { projectPath?: string; testFile?: string } + ) => { + console.log('[Mock] Starting tests:', { worktreePath, options }); + return { + success: true, + result: { + sessionId: 'mock-session-123', + worktreePath, + command: 'npm run test', + status: 'running' as const, + testFile: options?.testFile, + message: 'Tests started (mock)', + }, + }; + }, + + stopTests: async (sessionId: string) => { + console.log('[Mock] Stopping tests:', { sessionId }); + return { + success: true, + result: { + sessionId, + message: 'Tests stopped (mock)', + }, + }; + }, + + getTestLogs: async (worktreePath?: string, sessionId?: string) => { + console.log('[Mock] Getting test logs:', { worktreePath, sessionId }); + return { + success: false, + error: 'No test sessions found (mock)', + }; + }, + + onTestRunnerEvent: (callback) => { + console.log('[Mock] Subscribing to test runner events'); + // Return unsubscribe function + return () => { + console.log('[Mock] Unsubscribing from test runner events'); + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index dbfddc4c..3d818da3 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -562,6 +562,9 @@ type EventType = | 'dev-server:started' | 'dev-server:output' | 'dev-server:stopped' + | 'test-runner:started' + | 'test-runner:output' + | 'test-runner:completed' | 'notification:created'; /** @@ -593,6 +596,44 @@ export type DevServerLogEvent = | { type: 'dev-server:output'; payload: DevServerOutputEvent } | { type: 'dev-server:stopped'; payload: DevServerStoppedEvent }; +/** + * Test runner event payloads for WebSocket streaming + */ +export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error'; + +export interface TestRunnerStartedEvent { + sessionId: string; + worktreePath: string; + /** The test command being run (from project settings) */ + command: string; + testFile?: string; + timestamp: string; +} + +export interface TestRunnerOutputEvent { + sessionId: string; + worktreePath: string; + content: string; + timestamp: string; +} + +export interface TestRunnerCompletedEvent { + sessionId: string; + worktreePath: string; + /** The test command that was run */ + command: string; + status: TestRunStatus; + testFile?: string; + exitCode: number | null; + duration: number; + timestamp: string; +} + +export type TestRunnerEvent = + | { type: 'test-runner:started'; payload: TestRunnerStartedEvent } + | { type: 'test-runner:output'; payload: TestRunnerOutputEvent } + | { type: 'test-runner:completed'; payload: TestRunnerCompletedEvent }; + /** * Response type for fetching dev server logs */ @@ -608,6 +649,26 @@ export interface DevServerLogsResponse { error?: string; } +/** + * Response type for fetching test logs + */ +export interface TestLogsResponse { + success: boolean; + result?: { + sessionId: string; + worktreePath: string; + /** The test command that was/is being run */ + command: string; + status: TestRunStatus; + testFile?: string; + logs: string; + startedAt: string; + finishedAt: string | null; + exitCode: number | null; + }; + error?: string; +} + type EventCallback = (payload: unknown) => void; interface EnhancePromptResult { @@ -1885,6 +1946,32 @@ export class HttpApiClient implements ElectronAPI { unsub3(); }; }, + // Test runner methods + startTests: (worktreePath: string, options?: { projectPath?: string; testFile?: string }) => + this.post('/api/worktree/start-tests', { worktreePath, ...options }), + stopTests: (sessionId: string) => this.post('/api/worktree/stop-tests', { sessionId }), + getTestLogs: (worktreePath?: string, sessionId?: string): Promise => { + const params = new URLSearchParams(); + if (worktreePath) params.append('worktreePath', worktreePath); + if (sessionId) params.append('sessionId', sessionId); + return this.get(`/api/worktree/test-logs?${params.toString()}`); + }, + onTestRunnerEvent: (callback: (event: TestRunnerEvent) => void) => { + const unsub1 = this.subscribeToEvent('test-runner:started', (payload) => + callback({ type: 'test-runner:started', payload: payload as TestRunnerStartedEvent }) + ); + const unsub2 = this.subscribeToEvent('test-runner:output', (payload) => + callback({ type: 'test-runner:output', payload: payload as TestRunnerOutputEvent }) + ); + const unsub3 = this.subscribeToEvent('test-runner:completed', (payload) => + callback({ type: 'test-runner:completed', payload: payload as TestRunnerCompletedEvent }) + ); + return () => { + unsub1(); + unsub2(); + unsub3(); + }; + }, }; // Git API @@ -2246,6 +2333,7 @@ export class HttpApiClient implements ElectronAPI { defaultDeleteBranchWithWorktree?: boolean; autoDismissInitScriptIndicator?: boolean; lastSelectedSessionId?: string; + testCommand?: string; }; error?: string; }> => this.post('/api/settings/project', { projectPath }), diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index ecb78220..5eef480d 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -506,6 +506,7 @@ export interface ProjectAnalysis { // Terminal panel layout types (recursive for splits) export type TerminalPanelContent = | { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string } + | { type: 'testRunner'; sessionId: string; size?: number; worktreePath: string } | { type: 'split'; id: string; // Stable ID for React key stability @@ -543,6 +544,7 @@ export interface TerminalState { // Used to restore terminal layout structure when switching projects export type PersistedTerminalPanel = | { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string } + | { type: 'testRunner'; size?: number; sessionId?: string; worktreePath?: string } | { type: 'split'; id?: string; // Optional for backwards compatibility with older persisted layouts @@ -3171,7 +3173,7 @@ export const useAppStore = create()((set, get) => ({ targetId: string, targetDirection: 'horizontal' | 'vertical' ): TerminalPanelContent => { - if (node.type === 'terminal') { + if (node.type === 'terminal' || node.type === 'testRunner') { if (node.sessionId === targetId) { // Found the target - split it return { @@ -3196,7 +3198,7 @@ export const useAppStore = create()((set, get) => ({ node: TerminalPanelContent, targetDirection: 'horizontal' | 'vertical' ): TerminalPanelContent => { - if (node.type === 'terminal') { + if (node.type === 'terminal' || node.type === 'testRunner') { return { type: 'split', id: generateSplitId(), @@ -3204,7 +3206,7 @@ export const useAppStore = create()((set, get) => ({ panels: [{ ...node, size: 50 }, newTerminal], }; } - // If same direction, add to existing split + // It's a split - if same direction, add to existing split if (node.direction === targetDirection) { const newSize = 100 / (node.panels.length + 1); return { @@ -3253,7 +3255,7 @@ export const useAppStore = create()((set, get) => ({ // Find which tab contains this session const findFirstTerminal = (node: TerminalPanelContent | null): string | null => { if (!node) return null; - if (node.type === 'terminal') return node.sessionId; + if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId; for (const panel of node.panels) { const found = findFirstTerminal(panel); if (found) return found; @@ -3262,7 +3264,7 @@ export const useAppStore = create()((set, get) => ({ }; const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { - if (node.type === 'terminal') { + if (node.type === 'terminal' || node.type === 'testRunner') { return node.sessionId === sessionId ? null : node; } const newPanels: TerminalPanelContent[] = []; @@ -3321,6 +3323,10 @@ export const useAppStore = create()((set, get) => ({ if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 }; return node; } + if (node.type === 'testRunner') { + // testRunner panels don't participate in swapping + return node; + } return { ...node, panels: node.panels.map(swapInLayout) }; }; @@ -3373,6 +3379,10 @@ export const useAppStore = create()((set, get) => ({ } return node; } + if (node.type === 'testRunner') { + // testRunner panels don't have fontSize + return node; + } return { ...node, panels: node.panels.map(updateFontSize) }; }; @@ -3486,7 +3496,7 @@ export const useAppStore = create()((set, get) => ({ if (newActiveTabId) { const newActiveTab = newTabs.find((t) => t.id === newActiveTabId); const findFirst = (node: TerminalPanelContent): string | null => { - if (node.type === 'terminal') return node.sessionId; + if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId; for (const p of node.panels) { const f = findFirst(p); if (f) return f; @@ -3517,7 +3527,7 @@ export const useAppStore = create()((set, get) => ({ let newActiveSessionId = current.activeSessionId; if (tab.layout) { const findFirst = (node: TerminalPanelContent): string | null => { - if (node.type === 'terminal') return node.sessionId; + if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId; for (const p of node.panels) { const f = findFirst(p); if (f) return f; @@ -3578,6 +3588,10 @@ export const useAppStore = create()((set, get) => ({ if (node.type === 'terminal') { return node.sessionId === sessionId ? node : null; } + if (node.type === 'testRunner') { + // testRunner panels don't participate in moveTerminalToTab + return null; + } for (const panel of node.panels) { const found = findTerminal(panel); if (found) return found; @@ -3602,7 +3616,7 @@ export const useAppStore = create()((set, get) => ({ if (!sourceTab?.layout) return; const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { - if (node.type === 'terminal') { + if (node.type === 'terminal' || node.type === 'testRunner') { return node.sessionId === sessionId ? null : node; } const newPanels: TerminalPanelContent[] = []; @@ -3663,7 +3677,7 @@ export const useAppStore = create()((set, get) => ({ size: 100, fontSize: originalTerminalNode.fontSize, }; - } else if (targetTab.layout.type === 'terminal') { + } else if (targetTab.layout.type === 'terminal' || targetTab.layout.type === 'testRunner') { newTargetLayout = { type: 'split', id: generateSplitId(), @@ -3671,6 +3685,7 @@ export const useAppStore = create()((set, get) => ({ panels: [{ ...targetTab.layout, size: 50 }, terminalNode], }; } else { + // It's a split newTargetLayout = { ...targetTab.layout, panels: [...targetTab.layout.panels, terminalNode], @@ -3713,7 +3728,7 @@ export const useAppStore = create()((set, get) => ({ if (!tab.layout) { newLayout = { type: 'terminal', sessionId, size: 100, branchName }; - } else if (tab.layout.type === 'terminal') { + } else if (tab.layout.type === 'terminal' || tab.layout.type === 'testRunner') { newLayout = { type: 'split', id: generateSplitId(), @@ -3721,6 +3736,7 @@ export const useAppStore = create()((set, get) => ({ panels: [{ ...tab.layout, size: 50 }, terminalNode], }; } else { + // It's a split if (tab.layout.direction === direction) { const newSize = 100 / (tab.layout.panels.length + 1); newLayout = { @@ -3761,7 +3777,7 @@ export const useAppStore = create()((set, get) => ({ // Find first terminal in layout if no activeSessionId provided const findFirst = (node: TerminalPanelContent): string | null => { - if (node.type === 'terminal') return node.sessionId; + if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId; for (const p of node.panels) { const found = findFirst(p); if (found) return found; @@ -3794,7 +3810,7 @@ export const useAppStore = create()((set, get) => ({ // Helper to generate panel key (matches getPanelKey in terminal-view.tsx) const getPanelKey = (panel: TerminalPanelContent): string => { - if (panel.type === 'terminal') return panel.sessionId; + if (panel.type === 'terminal' || panel.type === 'testRunner') return panel.sessionId; const childKeys = panel.panels.map(getPanelKey).join('-'); return `split-${panel.direction}-${childKeys}`; }; @@ -3804,7 +3820,7 @@ export const useAppStore = create()((set, get) => ({ const key = getPanelKey(panel); const newSize = sizeMap.get(key); - if (panel.type === 'terminal') { + if (panel.type === 'terminal' || panel.type === 'testRunner') { return newSize !== undefined ? { ...panel, size: newSize } : panel; } @@ -3847,6 +3863,14 @@ export const useAppStore = create()((set, get) => ({ branchName: panel.branchName, // Preserve branch name for display }; } + if (panel.type === 'testRunner') { + return { + type: 'testRunner', + size: panel.size, + sessionId: panel.sessionId, // Preserve for reconnection + worktreePath: panel.worktreePath, // Preserve worktree context + }; + } return { type: 'split', id: panel.id, // Preserve stable ID diff --git a/apps/ui/src/store/test-runners-store.ts b/apps/ui/src/store/test-runners-store.ts new file mode 100644 index 00000000..29a4cb6f --- /dev/null +++ b/apps/ui/src/store/test-runners-store.ts @@ -0,0 +1,248 @@ +/** + * Test Runners Store - State management for test runner sessions + */ + +import { create } from 'zustand'; +import type { TestRunStatus } from '@/types/electron'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * A test run session + */ +export interface TestSession { + /** Unique session ID */ + sessionId: string; + /** Path to the worktree where tests are running */ + worktreePath: string; + /** The test command being run (from project settings) */ + command: string; + /** Current status of the test run */ + status: TestRunStatus; + /** Optional: specific test file being run */ + testFile?: string; + /** When the test run started */ + startedAt: string; + /** When the test run finished (if completed) */ + finishedAt?: string; + /** Exit code (if completed) */ + exitCode?: number | null; + /** Duration in milliseconds (if completed) */ + duration?: number; + /** Accumulated output logs */ + output: string; +} + +// ============================================================================ +// State Interface +// ============================================================================ + +interface TestRunnersState { + /** Map of sessionId -> TestSession for all tracked sessions */ + sessions: Record; + /** Map of worktreePath -> sessionId for quick lookup of active session per worktree */ + activeSessionByWorktree: Record; + /** Loading state for initial data fetch */ + isLoading: boolean; + /** Error state */ + error: string | null; +} + +// ============================================================================ +// Actions Interface +// ============================================================================ + +interface TestRunnersActions { + /** Add or update a session when a test run starts */ + startSession: (session: Omit) => void; + + /** Append output to a session */ + appendOutput: (sessionId: string, content: string) => void; + + /** Complete a session with final status */ + completeSession: ( + sessionId: string, + status: TestRunStatus, + exitCode: number | null, + duration: number + ) => void; + + /** Get the active session for a worktree */ + getActiveSession: (worktreePath: string) => TestSession | null; + + /** Get a session by ID */ + getSession: (sessionId: string) => TestSession | null; + + /** Check if a worktree has an active (running) test session */ + isWorktreeRunning: (worktreePath: string) => boolean; + + /** Remove a session (cleanup) */ + removeSession: (sessionId: string) => void; + + /** Clear all sessions for a worktree */ + clearWorktreeSessions: (worktreePath: string) => void; + + /** Set loading state */ + setLoading: (loading: boolean) => void; + + /** Set error state */ + setError: (error: string | null) => void; + + /** Reset the store */ + reset: () => void; +} + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState: TestRunnersState = { + sessions: {}, + activeSessionByWorktree: {}, + isLoading: false, + error: null, +}; + +// ============================================================================ +// Store +// ============================================================================ + +export const useTestRunnersStore = create((set, get) => ({ + ...initialState, + + startSession: (session) => { + const newSession: TestSession = { + ...session, + output: '', + }; + + set((state) => ({ + sessions: { + ...state.sessions, + [session.sessionId]: newSession, + }, + activeSessionByWorktree: { + ...state.activeSessionByWorktree, + [session.worktreePath]: session.sessionId, + }, + })); + }, + + appendOutput: (sessionId, content) => { + set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + + return { + sessions: { + ...state.sessions, + [sessionId]: { + ...session, + output: session.output + content, + }, + }, + }; + }); + }, + + completeSession: (sessionId, status, exitCode, duration) => { + set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + + const finishedAt = new Date().toISOString(); + + // Remove from active sessions since it's no longer running + const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; + + return { + sessions: { + ...state.sessions, + [sessionId]: { + ...session, + status, + exitCode, + duration, + finishedAt, + }, + }, + // Only remove from active if this is the current active session + activeSessionByWorktree: + state.activeSessionByWorktree[session.worktreePath] === sessionId + ? remainingActive + : state.activeSessionByWorktree, + }; + }); + }, + + getActiveSession: (worktreePath) => { + const state = get(); + const sessionId = state.activeSessionByWorktree[worktreePath]; + if (!sessionId) return null; + return state.sessions[sessionId] || null; + }, + + getSession: (sessionId) => { + return get().sessions[sessionId] || null; + }, + + isWorktreeRunning: (worktreePath) => { + const state = get(); + const sessionId = state.activeSessionByWorktree[worktreePath]; + if (!sessionId) return false; + const session = state.sessions[sessionId]; + return session?.status === 'running' || session?.status === 'pending'; + }, + + removeSession: (sessionId) => { + set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [sessionId]: _, ...remainingSessions } = state.sessions; + + // Remove from active if this was the active session + const { [session.worktreePath]: activeId, ...remainingActive } = + state.activeSessionByWorktree; + + return { + sessions: remainingSessions, + activeSessionByWorktree: + activeId === sessionId ? remainingActive : state.activeSessionByWorktree, + }; + }); + }, + + clearWorktreeSessions: (worktreePath) => { + set((state) => { + // Find all sessions for this worktree + const sessionsToRemove = Object.values(state.sessions) + .filter((s) => s.worktreePath === worktreePath) + .map((s) => s.sessionId); + + // Remove them from sessions + const remainingSessions = { ...state.sessions }; + sessionsToRemove.forEach((id) => { + delete remainingSessions[id]; + }); + + // Remove from active + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; + + return { + sessions: remainingSessions, + activeSessionByWorktree: remainingActive, + }; + }); + }, + + setLoading: (loading) => set({ isLoading: loading }), + + setError: (error) => set({ error }), + + reset: () => set(initialState), +})); diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index f98f58a9..5c53da9a 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -1267,6 +1267,107 @@ export interface WorktreeAPI { }; error?: string; }>; + + // Test runner methods + + // Start tests for a worktree + startTests: ( + worktreePath: string, + options?: { projectPath?: string; testFile?: string } + ) => Promise<{ + success: boolean; + result?: { + sessionId: string; + worktreePath: string; + /** The test command being run (from project settings) */ + command: string; + status: TestRunStatus; + testFile?: string; + message: string; + }; + error?: string; + }>; + + // Stop a running test session + stopTests: (sessionId: string) => Promise<{ + success: boolean; + result?: { + sessionId: string; + message: string; + }; + error?: string; + }>; + + // Get test logs for a session + getTestLogs: ( + worktreePath?: string, + sessionId?: string + ) => Promise<{ + success: boolean; + result?: { + sessionId: string; + worktreePath: string; + command: string; + status: TestRunStatus; + testFile?: string; + logs: string; + startedAt: string; + finishedAt: string | null; + exitCode: number | null; + }; + error?: string; + }>; + + // Subscribe to test runner events (started, output, completed) + onTestRunnerEvent: ( + callback: ( + event: + | { + type: 'test-runner:started'; + payload: TestRunnerStartedEvent; + } + | { + type: 'test-runner:output'; + payload: TestRunnerOutputEvent; + } + | { + type: 'test-runner:completed'; + payload: TestRunnerCompletedEvent; + } + ) => void + ) => () => void; +} + +// Test runner status type +export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error'; + +// Test runner event payloads +export interface TestRunnerStartedEvent { + sessionId: string; + worktreePath: string; + /** The test command being run (from project settings) */ + command: string; + testFile?: string; + timestamp: string; +} + +export interface TestRunnerOutputEvent { + sessionId: string; + worktreePath: string; + content: string; + timestamp: string; +} + +export interface TestRunnerCompletedEvent { + sessionId: string; + worktreePath: string; + /** The test command that was run */ + command: string; + status: TestRunStatus; + testFile?: string; + exitCode: number | null; + duration: number; + timestamp: string; } export interface GitAPI { diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index c274ffb5..43f1d3d4 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -47,6 +47,12 @@ export type EventType = | 'dev-server:started' | 'dev-server:output' | 'dev-server:stopped' + | 'test-runner:started' + | 'test-runner:progress' + | 'test-runner:output' + | 'test-runner:completed' + | 'test-runner:error' + | 'test-runner:result' | 'notification:created'; export type EventCallback = (type: EventType, payload: unknown) => void; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a1b48434..d29981ef 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -335,3 +335,6 @@ export { PR_STATES, validatePRState } from './worktree.js'; // Terminal types export type { TerminalInfo } from './terminal.js'; + +// Test runner types +export type { TestRunnerInfo } from './test-runner.js'; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 35de27e5..cf2de7e4 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1182,6 +1182,14 @@ export interface ProjectSettings { /** Maximum concurrent agents for this project (overrides global maxConcurrency) */ maxConcurrentAgents?: number; + // Test Runner Configuration + /** + * Custom command to run tests for this project. + * If not specified, auto-detection will be used based on project structure. + * Examples: "npm test", "yarn test", "pnpm test", "pytest", "go test ./..." + */ + testCommand?: string; + // Phase Model Overrides (per-project) /** * Override phase model settings for this project. diff --git a/libs/types/src/test-runner.ts b/libs/types/src/test-runner.ts new file mode 100644 index 00000000..20c61a34 --- /dev/null +++ b/libs/types/src/test-runner.ts @@ -0,0 +1,17 @@ +/** + * Test runner types for the test runner functionality + */ + +/** + * Information about an available test runner + */ +export interface TestRunnerInfo { + /** Unique identifier for the test runner (e.g., 'vitest', 'jest', 'pytest') */ + id: string; + /** Display name of the test runner (e.g., "Vitest", "Jest", "Pytest") */ + name: string; + /** CLI command to run all tests */ + command: string; + /** Optional: CLI command pattern to run a specific test file */ + fileCommand?: string; +}