Merge pull request #645 from AutoMaker-Org/feat/test-runner

feat(tests): implement test runner functionality with API integration
This commit is contained in:
Shirone
2026-01-21 16:25:13 +00:00
committed by GitHub
28 changed files with 3400 additions and 19 deletions

View File

@@ -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);

View File

@@ -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());

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<string, TestRunSession> = 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<boolean> {
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<TestRunResult> {
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<void> {
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<void> {
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 };

View File

@@ -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: <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />,
};
case 'pending':
return {
text: 'Pending',
className: 'bg-amber-500/10 text-amber-500',
icon: <Clock className="w-3 h-3" />,
};
case 'passed':
return {
text: 'Passed',
className: 'bg-green-500/10 text-green-500',
icon: <CheckCircle2 className="w-3 h-3" />,
};
case 'failed':
return {
text: 'Failed',
className: 'bg-red-500/10 text-red-500',
icon: <XCircle className="w-3 h-3" />,
};
case 'cancelled':
return {
text: 'Cancelled',
className: 'bg-yellow-500/10 text-yellow-500',
icon: <AlertCircle className="w-3 h-3" />,
};
case 'error':
return {
text: 'Error',
className: 'bg-red-500/10 text-red-500',
icon: <AlertCircle className="w-3 h-3" />,
};
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<XtermLogViewerRef>(null);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const lastLogsLengthRef = useRef(0);
const lastSessionIdRef = useRef<string | null>(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 */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base">
<FlaskConical className="w-4 h-4 text-primary" />
<span>Test Runner</span>
{status && (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium',
statusIndicator.className
)}
>
{statusIndicator.icon}
{statusIndicator.text}
</span>
)}
{formattedDuration && !isRunning && (
<span className="text-xs text-muted-foreground font-mono">{formattedDuration}</span>
)}
</DialogTitle>
<div className="flex items-center gap-1.5">
{isRunning && onStopTests && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onStopTests}
>
<Square className="w-3 h-3 mr-1.5 fill-current" />
Stop
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => fetchLogs()}
title="Refresh logs"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
{/* Info bar */}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{branch && (
<span className="inline-flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
<span className="font-medium text-foreground/80">{branch}</span>
</span>
)}
{command && (
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground/60">Command</span>
<span className="font-mono text-primary truncate max-w-[200px]">{command}</span>
</span>
)}
{testFile && (
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground/60">File</span>
<span className="font-mono truncate max-w-[150px]">{testFile}</span>
</span>
)}
{formattedStartTime && (
<span className="inline-flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{formattedStartTime}
</span>
)}
</div>
</DialogHeader>
{/* Error displays */}
{error && (
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>{error}</span>
</div>
</div>
)}
{/* Log content area */}
<div className="flex-1 min-h-0 overflow-hidden bg-zinc-950" data-testid="test-logs-content">
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
<Spinner size="md" className="mr-2" />
<span className="text-sm">Loading logs...</span>
</div>
) : !logs && !isRunning && !status ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<Terminal className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm">No test run active</p>
<p className="text-xs mt-1 opacity-60">Start a test run to see logs here</p>
</div>
) : isRunning && !logs ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<Spinner size="xl" className="mb-3" />
<p className="text-sm">Waiting for output...</p>
<p className="text-xs mt-1 opacity-60">Logs will appear as tests generate output</p>
</div>
) : (
<XtermLogViewer
ref={xtermRef}
className="h-full"
minHeight={280}
autoScroll={autoScrollEnabled}
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
onScrollToBottom={() => setAutoScrollEnabled(true)}
/>
)}
</div>
{/* Footer status bar */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
{exitCode !== null && (
<span className={cn('font-mono', exitCode === 0 ? 'text-green-500' : 'text-red-500')}>
Exit: {exitCode}
</span>
)}
</div>
{!autoScrollEnabled && logs && (
<button
onClick={scrollToBottom}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
>
<ArrowDown className="w-3 h-3" />
Scroll to bottom
</button>
)}
{autoScrollEnabled && logs && (
<span className="inline-flex items-center gap-1.5 opacity-60">
<ArrowDown className="w-3 h-3" />
Auto-scroll
</span>
)}
</div>
</>
);
}
// ============================================================================
// 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 (
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<SheetContent side="bottom" className="h-[80vh] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Test Logs</SheetTitle>
</SheetHeader>
<TestLogsPanelContent
worktreePath={worktreePath}
branch={branch}
sessionId={sessionId}
onStopTests={onStopTests}
/>
</SheetContent>
</Sheet>
);
}
// Desktop: use Dialog
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
data-testid="test-logs-panel"
compact
>
<TestLogsPanelContent
worktreePath={worktreePath}
branch={branch}
sessionId={sessionId}
onStopTests={onStopTests}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -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({
<DropdownMenuSeparator />
</>
)}
{/* Test Runner section - only show when test command is configured */}
{hasTestCommand && onStartTests && (
<>
{isTestRunning ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
Tests Running
</DropdownMenuLabel>
{onViewTestLogs && (
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Test Logs
</DropdownMenuItem>
)}
{onStopTests && (
<DropdownMenuItem
onClick={() => onStopTests(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Tests
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => onStartTests(worktree)}
disabled={isStartingTests}
className="text-xs"
>
<FlaskConical
className={cn('w-3.5 h-3.5 mr-2', isStartingTests && 'animate-pulse')}
/>
{isStartingTests ? 'Starting Tests...' : 'Run Tests'}
</DropdownMenuItem>
{onViewTestLogs && testSessionInfo && (
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Last Test Results
{testSessionInfo.status === 'passed' && (
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded">
passed
</span>
)}
{testSessionInfo.status === 'failed' && (
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 px-1.5 py-0.5 rounded">
failed
</span>
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
</>
)}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>

View File

@@ -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}
/>
</div>

View File

@@ -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;

View File

@@ -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<WorktreeInfo | null>(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<WorktreeInfo | null>(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 */}
<TestLogsPanel
open={testLogsPanelOpen}
onClose={handleCloseTestLogsPanel}
worktreePath={testLogsPanelWorktree?.path ?? null}
branch={testLogsPanelWorktree?.branch}
onStopTests={
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
}
/>
</div>
);
}
@@ -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}
/>
)}
</div>
@@ -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 */}
<TestLogsPanel
open={testLogsPanelOpen}
onClose={handleCloseTestLogsPanel}
worktreePath={testLogsPanelWorktree?.path ?? null}
branch={testLogsPanelWorktree?.branch}
onStopTests={
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
}
/>
</div>
);
}

View File

@@ -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 },

View File

@@ -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;

View File

@@ -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';

View File

@@ -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 <ProjectThemeSection project={currentProject} />;
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'testing':
return <TestingSection project={currentProject} />;
case 'claude':
return <ProjectModelsSection project={currentProject} />;
case 'danger':

View File

@@ -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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<FlaskConical className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Testing Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure how tests are run for this project.
</p>
</div>
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : (
<>
{/* Test Command Input */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="test-command" className="text-foreground font-medium">
Test Command
</Label>
{hasChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
<Input
id="test-command"
value={testCommand}
onChange={(e) => setTestCommand(e.target.value)}
placeholder="e.g., npm test, yarn test, pytest, go test ./..."
className="font-mono text-sm"
data-testid="test-command-input"
/>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
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.).
</p>
</div>
{/* Auto-detection Info */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Auto-detection</p>
<p>
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.).
</p>
</div>
</div>
{/* Quick Presets */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Quick Presets</Label>
<div className="flex flex-wrap gap-2">
{[
{ 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) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUsePreset(preset.command)}
className="text-xs font-mono"
>
{preset.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground/80">
Click a preset to use it as your test command.
</p>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -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';

View File

@@ -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 (
* <div>
* {isLoading && <Spinner />}
* {isRunning && <Badge>Running</Badge>}
* <pre>{logs}</pre>
* </div>
* );
* ```
*/
export function useTestLogs({
worktreePath,
sessionId: targetSessionId,
autoSubscribe = true,
}: UseTestLogsOptions) {
const [state, setState] = useState<TestLogState>(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<string | null>(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 };

View File

@@ -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<StartTestResult> => {
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<StopTestResult> => {
// 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 };

View File

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

View File

@@ -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<TestLogsResponse> => {
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 }),

View File

@@ -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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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<AppState & AppActions>()((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

View File

@@ -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<string, TestSession>;
/** Map of worktreePath -> sessionId for quick lookup of active session per worktree */
activeSessionByWorktree: Record<string, string>;
/** 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<TestSession, 'output'>) => 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<TestRunnersState & TestRunnersActions>((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),
}));

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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';

View File

@@ -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.

View File

@@ -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;
}