mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat(tests): implement test runner functionality with API integration
- Added Test Runner Service to manage test execution processes for worktrees. - Introduced endpoints for starting and stopping tests, and retrieving test logs. - Created UI components for displaying test logs and managing test sessions. - Integrated test runner events for real-time updates in the UI. - Updated project settings to include configurable test commands. This enhancement allows users to run tests directly from the UI, view logs in real-time, and manage test sessions effectively.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
createStartTestsHandler(settingsService)
|
||||
);
|
||||
router.post('/stop-tests', createStopTestsHandler());
|
||||
router.get('/test-logs', createGetTestLogsHandler());
|
||||
|
||||
// Init script routes
|
||||
router.get('/init-script', createGetInitScriptHandler());
|
||||
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
||||
|
||||
89
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
89
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 { worktreePath, projectPath, testFile } = req.body as {
|
||||
worktreePath: string;
|
||||
projectPath?: string;
|
||||
testFile?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath is required',
|
||||
});
|
||||
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;
|
||||
|
||||
// Debug logging
|
||||
console.log('[StartTests] settingsPath:', settingsPath);
|
||||
console.log('[StartTests] projectSettings:', JSON.stringify(projectSettings, null, 2));
|
||||
console.log('[StartTests] testCommand:', testCommand);
|
||||
console.log('[StartTests] testCommand type:', typeof 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
49
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
49
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 { sessionId } = req.body as {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId is required',
|
||||
});
|
||||
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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
133
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
133
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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({
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: result.result.sessionId,
|
||||
worktreePath: session?.worktreePath,
|
||||
command: session?.command,
|
||||
status: result.result.status,
|
||||
testFile: session?.testFile,
|
||||
logs: result.result.output,
|
||||
startedAt: result.result.startedAt,
|
||||
finishedAt: result.result.finishedAt,
|
||||
exitCode: session?.exitCode ?? null,
|
||||
},
|
||||
});
|
||||
} 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({
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: activeSession.id,
|
||||
worktreePath: activeSession.worktreePath,
|
||||
command: activeSession.command,
|
||||
status: result.result.status,
|
||||
testFile: activeSession.testFile,
|
||||
logs: result.result.output,
|
||||
startedAt: result.result.startedAt,
|
||||
finishedAt: result.result.finishedAt,
|
||||
exitCode: activeSession.exitCode,
|
||||
},
|
||||
});
|
||||
} 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({
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: mostRecent.sessionId,
|
||||
worktreePath: mostRecent.worktreePath,
|
||||
command: mostRecent.command,
|
||||
status: result.result.status,
|
||||
testFile: mostRecent.testFile,
|
||||
logs: result.result.output,
|
||||
startedAt: result.result.startedAt,
|
||||
finishedAt: result.result.finishedAt,
|
||||
exitCode: mostRecent.exitCode,
|
||||
},
|
||||
});
|
||||
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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
666
apps/server/src/services/test-runner-service.ts
Normal file
666
apps/server/src/services/test-runner-service.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// Append the test file to the command
|
||||
// Most test runners support: command -- file or command file
|
||||
finalCommand = `${command} -- ${testFile}`;
|
||||
}
|
||||
|
||||
// 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', async () => {
|
||||
if (testRunnerServiceInstance) {
|
||||
await testRunnerServiceInstance.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
if (testRunnerServiceInstance) {
|
||||
await testRunnerServiceInstance.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Export the class for testing purposes
|
||||
export { TestRunnerService };
|
||||
420
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
420
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
'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 '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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,191 @@ 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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 +585,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,7 +610,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -494,6 +695,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 +742,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 +771,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -596,6 +815,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 +844,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -703,6 +929,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 response = await httpClient.settings.updateProject(project.path, {
|
||||
testCommand: testCommand.trim() || undefined,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setOriginalTestCommand(testCommand);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
383
apps/ui/src/hooks/use-test-logs.ts
Normal file
383
apps/ui/src/hooks/use-test-logs.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getTestLogs) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Test logs API not available',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.getTestLogs(worktreePath ?? undefined, targetSessionId);
|
||||
|
||||
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) {
|
||||
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 };
|
||||
393
apps/ui/src/hooks/use-test-runners.ts
Normal file
393
apps/ui/src/hooks/use-test-runners.ts
Normal 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, runner, 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,
|
||||
runner,
|
||||
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 };
|
||||
@@ -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');
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
248
apps/ui/src/store/test-runners-store.ts
Normal file
248
apps/ui/src/store/test-runners-store.ts
Normal 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),
|
||||
}));
|
||||
101
apps/ui/src/types/electron.d.ts
vendored
101
apps/ui/src/types/electron.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
17
libs/types/src/test-runner.ts
Normal file
17
libs/types/src/test-runner.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user