mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +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 { getNotificationService } from './services/notification-service.js';
|
||||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||||
import { getEventHistoryService } from './services/event-history-service.js';
|
import { getEventHistoryService } from './services/event-history-service.js';
|
||||||
|
import { getTestRunnerService } from './services/test-runner-service.js';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -248,6 +249,10 @@ notificationService.setEventEmitter(events);
|
|||||||
// Initialize Event History Service
|
// Initialize Event History Service
|
||||||
const eventHistoryService = getEventHistoryService();
|
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)
|
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||||
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
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 { createStopDevHandler } from './routes/stop-dev.js';
|
||||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||||
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.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 {
|
import {
|
||||||
createGetInitScriptHandler,
|
createGetInitScriptHandler,
|
||||||
createPutInitScriptHandler,
|
createPutInitScriptHandler,
|
||||||
@@ -140,6 +143,15 @@ export function createWorktreeRoutes(
|
|||||||
createGetDevServerLogsHandler()
|
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
|
// Init script routes
|
||||||
router.get('/init-script', createGetInitScriptHandler());
|
router.get('/init-script', createGetInitScriptHandler());
|
||||||
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
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,
|
SplitSquareHorizontal,
|
||||||
Undo2,
|
Undo2,
|
||||||
Zap,
|
Zap,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
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 { TooltipWrapper } from './tooltip-wrapper';
|
||||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps {
|
|||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
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;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
@@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (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;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({
|
|||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
standalone = false,
|
standalone = false,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
|
hasTestCommand = false,
|
||||||
|
isStartingTests = false,
|
||||||
|
isTestRunning = false,
|
||||||
|
testSessionInfo,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
@@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({
|
|||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
onMerge,
|
onMerge,
|
||||||
|
onStartTests,
|
||||||
|
onStopTests,
|
||||||
|
onViewTestLogs,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
@@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSeparator />
|
<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 */}
|
{/* Auto Mode toggle */}
|
||||||
{onToggleAutoMode && (
|
{onToggleAutoMode && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
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 { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
|
|
||||||
@@ -33,6 +40,12 @@ interface WorktreeTabProps {
|
|||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
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;
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||||
@@ -59,7 +72,15 @@ interface WorktreeTabProps {
|
|||||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
onToggleAutoMode?: (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;
|
hasInitScript: boolean;
|
||||||
|
/** Whether a test command is configured in project settings */
|
||||||
|
hasTestCommand?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -85,6 +106,9 @@ export function WorktreeTab({
|
|||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
|
isStartingTests = false,
|
||||||
|
isTestRunning = false,
|
||||||
|
testSessionInfo,
|
||||||
onSelectWorktree,
|
onSelectWorktree,
|
||||||
onBranchDropdownOpenChange,
|
onBranchDropdownOpenChange,
|
||||||
onActionsDropdownOpenChange,
|
onActionsDropdownOpenChange,
|
||||||
@@ -111,7 +135,11 @@ export function WorktreeTab({
|
|||||||
onViewDevServerLogs,
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
|
onStartTests,
|
||||||
|
onStopTests,
|
||||||
|
onViewTestLogs,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
|
hasTestCommand = false,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
// Make the worktree tab a drop target for feature cards
|
// Make the worktree tab a drop target for feature cards
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
@@ -395,6 +423,10 @@ export function WorktreeTab({
|
|||||||
devServerInfo={devServerInfo}
|
devServerInfo={devServerInfo}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunning}
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunning}
|
||||||
|
testSessionInfo={testSessionInfo}
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
@@ -416,6 +448,9 @@ export function WorktreeTab({
|
|||||||
onViewDevServerLogs={onViewDevServerLogs}
|
onViewDevServerLogs={onViewDevServerLogs}
|
||||||
onRunInitScript={onRunInitScript}
|
onRunInitScript={onRunInitScript}
|
||||||
onToggleAutoMode={onToggleAutoMode}
|
onToggleAutoMode={onToggleAutoMode}
|
||||||
|
onStartTests={onStartTests}
|
||||||
|
onStopTests={onStopTests}
|
||||||
|
onViewTestLogs={onViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ export interface DevServerInfo {
|
|||||||
url: string;
|
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 {
|
export interface FeatureInfo {
|
||||||
id: string;
|
id: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { useWorktreeInitScript } from '@/hooks/queries';
|
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
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 {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
useDevServers,
|
useDevServers,
|
||||||
@@ -25,6 +32,7 @@ import {
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||||
import { Undo2 } from 'lucide-react';
|
import { Undo2 } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
|
||||||
@@ -161,6 +169,191 @@ export function WorktreePanel({
|
|||||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||||
const hasInitScript = initScriptData?.exists ?? false;
|
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
|
// View changes dialog state
|
||||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
@@ -392,6 +585,10 @@ export function WorktreePanel({
|
|||||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
onPull={handlePull}
|
onPull={handlePull}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
@@ -413,7 +610,11 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -494,6 +695,17 @@ export function WorktreePanel({
|
|||||||
onMerged={handleMerged}
|
onMerged={handleMerged}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Test Logs Panel */}
|
||||||
|
<TestLogsPanel
|
||||||
|
open={testLogsPanelOpen}
|
||||||
|
onClose={handleCloseTestLogsPanel}
|
||||||
|
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||||
|
branch={testLogsPanelWorktree?.branch}
|
||||||
|
onStopTests={
|
||||||
|
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -530,6 +742,9 @@ export function WorktreePanel({
|
|||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||||
@@ -556,7 +771,11 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -596,6 +815,9 @@ export function WorktreePanel({
|
|||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(worktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
@@ -622,7 +844,11 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -703,6 +929,17 @@ export function WorktreePanel({
|
|||||||
onMerged={handleMerged}
|
onMerged={handleMerged}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Test Logs Panel */}
|
||||||
|
<TestLogsPanel
|
||||||
|
open={testLogsPanelOpen}
|
||||||
|
onClose={handleCloseTestLogsPanel}
|
||||||
|
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||||
|
branch={testLogsPanelWorktree?.branch}
|
||||||
|
onStopTests={
|
||||||
|
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
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';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
export interface ProjectNavigationItem {
|
export interface ProjectNavigationItem {
|
||||||
@@ -11,6 +11,7 @@ export interface ProjectNavigationItem {
|
|||||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
|
{ id: 'testing', label: 'Testing', icon: FlaskConical },
|
||||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
|
export type ProjectSettingsViewId =
|
||||||
|
| 'identity'
|
||||||
|
| 'theme'
|
||||||
|
| 'worktrees'
|
||||||
|
| 'testing'
|
||||||
|
| 'claude'
|
||||||
|
| 'danger';
|
||||||
|
|
||||||
interface UseProjectSettingsViewOptions {
|
interface UseProjectSettingsViewOptions {
|
||||||
initialView?: ProjectSettingsViewId;
|
initialView?: ProjectSettingsViewId;
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
|
|||||||
export { ProjectIdentitySection } from './project-identity-section';
|
export { ProjectIdentitySection } from './project-identity-section';
|
||||||
export { ProjectThemeSection } from './project-theme-section';
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
export { TestingSection } from './testing-section';
|
||||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectIdentitySection } from './project-identity-section';
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
import { TestingSection } from './testing-section';
|
||||||
import { ProjectModelsSection } from './project-models-section';
|
import { ProjectModelsSection } from './project-models-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||||
@@ -85,6 +86,8 @@ export function ProjectSettingsView() {
|
|||||||
return <ProjectThemeSection project={currentProject} />;
|
return <ProjectThemeSection project={currentProject} />;
|
||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
|
case 'testing':
|
||||||
|
return <TestingSection project={currentProject} />;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
case 'danger':
|
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 { useResponsiveKanban } from './use-responsive-kanban';
|
||||||
export { useScrollTracking } from './use-scroll-tracking';
|
export { useScrollTracking } from './use-scroll-tracking';
|
||||||
export { useSettingsMigration } from './use-settings-migration';
|
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';
|
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:started'
|
||||||
| 'dev-server:output'
|
| 'dev-server:output'
|
||||||
| 'dev-server:stopped'
|
| 'dev-server:stopped'
|
||||||
|
| 'test-runner:started'
|
||||||
|
| 'test-runner:output'
|
||||||
|
| 'test-runner:completed'
|
||||||
| 'notification:created';
|
| 'notification:created';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -593,6 +596,44 @@ export type DevServerLogEvent =
|
|||||||
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
|
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
|
||||||
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent };
|
| { 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
|
* Response type for fetching dev server logs
|
||||||
*/
|
*/
|
||||||
@@ -608,6 +649,26 @@ export interface DevServerLogsResponse {
|
|||||||
error?: string;
|
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;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
interface EnhancePromptResult {
|
interface EnhancePromptResult {
|
||||||
@@ -1885,6 +1946,32 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
unsub3();
|
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
|
// Git API
|
||||||
@@ -2246,6 +2333,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
defaultDeleteBranchWithWorktree?: boolean;
|
defaultDeleteBranchWithWorktree?: boolean;
|
||||||
autoDismissInitScriptIndicator?: boolean;
|
autoDismissInitScriptIndicator?: boolean;
|
||||||
lastSelectedSessionId?: string;
|
lastSelectedSessionId?: string;
|
||||||
|
testCommand?: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post('/api/settings/project', { projectPath }),
|
}> => this.post('/api/settings/project', { projectPath }),
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export interface ProjectAnalysis {
|
|||||||
// Terminal panel layout types (recursive for splits)
|
// Terminal panel layout types (recursive for splits)
|
||||||
export type TerminalPanelContent =
|
export type TerminalPanelContent =
|
||||||
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
|
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
|
||||||
|
| { type: 'testRunner'; sessionId: string; size?: number; worktreePath: string }
|
||||||
| {
|
| {
|
||||||
type: 'split';
|
type: 'split';
|
||||||
id: string; // Stable ID for React key stability
|
id: string; // Stable ID for React key stability
|
||||||
@@ -543,6 +544,7 @@ export interface TerminalState {
|
|||||||
// Used to restore terminal layout structure when switching projects
|
// Used to restore terminal layout structure when switching projects
|
||||||
export type PersistedTerminalPanel =
|
export type PersistedTerminalPanel =
|
||||||
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
|
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
|
||||||
|
| { type: 'testRunner'; size?: number; sessionId?: string; worktreePath?: string }
|
||||||
| {
|
| {
|
||||||
type: 'split';
|
type: 'split';
|
||||||
id?: string; // Optional for backwards compatibility with older persisted layouts
|
id?: string; // Optional for backwards compatibility with older persisted layouts
|
||||||
@@ -3171,7 +3173,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
targetId: string,
|
targetId: string,
|
||||||
targetDirection: 'horizontal' | 'vertical'
|
targetDirection: 'horizontal' | 'vertical'
|
||||||
): TerminalPanelContent => {
|
): TerminalPanelContent => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
if (node.sessionId === targetId) {
|
if (node.sessionId === targetId) {
|
||||||
// Found the target - split it
|
// Found the target - split it
|
||||||
return {
|
return {
|
||||||
@@ -3196,7 +3198,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
node: TerminalPanelContent,
|
node: TerminalPanelContent,
|
||||||
targetDirection: 'horizontal' | 'vertical'
|
targetDirection: 'horizontal' | 'vertical'
|
||||||
): TerminalPanelContent => {
|
): TerminalPanelContent => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
return {
|
return {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: generateSplitId(),
|
id: generateSplitId(),
|
||||||
@@ -3204,7 +3206,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
panels: [{ ...node, size: 50 }, newTerminal],
|
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) {
|
if (node.direction === targetDirection) {
|
||||||
const newSize = 100 / (node.panels.length + 1);
|
const newSize = 100 / (node.panels.length + 1);
|
||||||
return {
|
return {
|
||||||
@@ -3253,7 +3255,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Find which tab contains this session
|
// Find which tab contains this session
|
||||||
const findFirstTerminal = (node: TerminalPanelContent | null): string | null => {
|
const findFirstTerminal = (node: TerminalPanelContent | null): string | null => {
|
||||||
if (!node) return 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) {
|
for (const panel of node.panels) {
|
||||||
const found = findFirstTerminal(panel);
|
const found = findFirstTerminal(panel);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
@@ -3262,7 +3264,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
return node.sessionId === sessionId ? null : node;
|
return node.sessionId === sessionId ? null : node;
|
||||||
}
|
}
|
||||||
const newPanels: TerminalPanelContent[] = [];
|
const newPanels: TerminalPanelContent[] = [];
|
||||||
@@ -3321,6 +3323,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 };
|
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 };
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
if (node.type === 'testRunner') {
|
||||||
|
// testRunner panels don't participate in swapping
|
||||||
|
return node;
|
||||||
|
}
|
||||||
return { ...node, panels: node.panels.map(swapInLayout) };
|
return { ...node, panels: node.panels.map(swapInLayout) };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3373,6 +3379,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
if (node.type === 'testRunner') {
|
||||||
|
// testRunner panels don't have fontSize
|
||||||
|
return node;
|
||||||
|
}
|
||||||
return { ...node, panels: node.panels.map(updateFontSize) };
|
return { ...node, panels: node.panels.map(updateFontSize) };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3486,7 +3496,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (newActiveTabId) {
|
if (newActiveTabId) {
|
||||||
const newActiveTab = newTabs.find((t) => t.id === newActiveTabId);
|
const newActiveTab = newTabs.find((t) => t.id === newActiveTabId);
|
||||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
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) {
|
for (const p of node.panels) {
|
||||||
const f = findFirst(p);
|
const f = findFirst(p);
|
||||||
if (f) return f;
|
if (f) return f;
|
||||||
@@ -3517,7 +3527,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
let newActiveSessionId = current.activeSessionId;
|
let newActiveSessionId = current.activeSessionId;
|
||||||
if (tab.layout) {
|
if (tab.layout) {
|
||||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
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) {
|
for (const p of node.panels) {
|
||||||
const f = findFirst(p);
|
const f = findFirst(p);
|
||||||
if (f) return f;
|
if (f) return f;
|
||||||
@@ -3578,6 +3588,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal') {
|
||||||
return node.sessionId === sessionId ? node : null;
|
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) {
|
for (const panel of node.panels) {
|
||||||
const found = findTerminal(panel);
|
const found = findTerminal(panel);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
@@ -3602,7 +3616,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (!sourceTab?.layout) return;
|
if (!sourceTab?.layout) return;
|
||||||
|
|
||||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
return node.sessionId === sessionId ? null : node;
|
return node.sessionId === sessionId ? null : node;
|
||||||
}
|
}
|
||||||
const newPanels: TerminalPanelContent[] = [];
|
const newPanels: TerminalPanelContent[] = [];
|
||||||
@@ -3663,7 +3677,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
size: 100,
|
size: 100,
|
||||||
fontSize: originalTerminalNode.fontSize,
|
fontSize: originalTerminalNode.fontSize,
|
||||||
};
|
};
|
||||||
} else if (targetTab.layout.type === 'terminal') {
|
} else if (targetTab.layout.type === 'terminal' || targetTab.layout.type === 'testRunner') {
|
||||||
newTargetLayout = {
|
newTargetLayout = {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: generateSplitId(),
|
id: generateSplitId(),
|
||||||
@@ -3671,6 +3685,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
// It's a split
|
||||||
newTargetLayout = {
|
newTargetLayout = {
|
||||||
...targetTab.layout,
|
...targetTab.layout,
|
||||||
panels: [...targetTab.layout.panels, terminalNode],
|
panels: [...targetTab.layout.panels, terminalNode],
|
||||||
@@ -3713,7 +3728,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
if (!tab.layout) {
|
if (!tab.layout) {
|
||||||
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
|
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
|
||||||
} else if (tab.layout.type === 'terminal') {
|
} else if (tab.layout.type === 'terminal' || tab.layout.type === 'testRunner') {
|
||||||
newLayout = {
|
newLayout = {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: generateSplitId(),
|
id: generateSplitId(),
|
||||||
@@ -3721,6 +3736,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
// It's a split
|
||||||
if (tab.layout.direction === direction) {
|
if (tab.layout.direction === direction) {
|
||||||
const newSize = 100 / (tab.layout.panels.length + 1);
|
const newSize = 100 / (tab.layout.panels.length + 1);
|
||||||
newLayout = {
|
newLayout = {
|
||||||
@@ -3761,7 +3777,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
// Find first terminal in layout if no activeSessionId provided
|
// Find first terminal in layout if no activeSessionId provided
|
||||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
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) {
|
for (const p of node.panels) {
|
||||||
const found = findFirst(p);
|
const found = findFirst(p);
|
||||||
if (found) return found;
|
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)
|
// Helper to generate panel key (matches getPanelKey in terminal-view.tsx)
|
||||||
const getPanelKey = (panel: TerminalPanelContent): string => {
|
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('-');
|
const childKeys = panel.panels.map(getPanelKey).join('-');
|
||||||
return `split-${panel.direction}-${childKeys}`;
|
return `split-${panel.direction}-${childKeys}`;
|
||||||
};
|
};
|
||||||
@@ -3804,7 +3820,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
const key = getPanelKey(panel);
|
const key = getPanelKey(panel);
|
||||||
const newSize = sizeMap.get(key);
|
const newSize = sizeMap.get(key);
|
||||||
|
|
||||||
if (panel.type === 'terminal') {
|
if (panel.type === 'terminal' || panel.type === 'testRunner') {
|
||||||
return newSize !== undefined ? { ...panel, size: newSize } : panel;
|
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
|
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 {
|
return {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: panel.id, // Preserve stable ID
|
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;
|
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 {
|
export interface GitAPI {
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ export type EventType =
|
|||||||
| 'dev-server:started'
|
| 'dev-server:started'
|
||||||
| 'dev-server:output'
|
| 'dev-server:output'
|
||||||
| 'dev-server:stopped'
|
| 'dev-server:stopped'
|
||||||
|
| 'test-runner:started'
|
||||||
|
| 'test-runner:progress'
|
||||||
|
| 'test-runner:output'
|
||||||
|
| 'test-runner:completed'
|
||||||
|
| 'test-runner:error'
|
||||||
|
| 'test-runner:result'
|
||||||
| 'notification:created';
|
| 'notification:created';
|
||||||
|
|
||||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||||
|
|||||||
@@ -335,3 +335,6 @@ export { PR_STATES, validatePRState } from './worktree.js';
|
|||||||
|
|
||||||
// Terminal types
|
// Terminal types
|
||||||
export type { TerminalInfo } from './terminal.js';
|
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) */
|
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */
|
||||||
maxConcurrentAgents?: number;
|
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)
|
// Phase Model Overrides (per-project)
|
||||||
/**
|
/**
|
||||||
* Override phase model settings for this 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