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:
Shirone
2026-01-21 15:45:33 +01:00
parent c3e7e57968
commit afa93dde0d
28 changed files with 3322 additions and 19 deletions

View File

@@ -8,4 +8,18 @@ export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './
export { useResponsiveKanban } from './use-responsive-kanban';
export { useScrollTracking } from './use-scroll-tracking';
export { useSettingsMigration } from './use-settings-migration';
export {
useTestRunners,
useTestRunnerEvents,
type StartTestOptions,
type StartTestResult,
type StopTestResult,
type TestSession,
} from './use-test-runners';
export {
useTestLogs,
useTestLogEvents,
type TestLogState,
type UseTestLogsOptions,
} from './use-test-logs';
export { useWindowState } from './use-window-state';

View File

@@ -0,0 +1,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 };

View File

@@ -0,0 +1,393 @@
/**
* useTestRunners - Hook for test runner lifecycle management
*
* This hook provides a complete interface for:
* - Starting and stopping test runs
* - Subscribing to test runner events (started, output, completed)
* - Managing test session state per worktree
* - Fetching existing test logs
*/
import { useEffect, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { useTestRunnersStore, type TestSession } from '@/store/test-runners-store';
import type {
TestRunStatus,
TestRunnerStartedEvent,
TestRunnerOutputEvent,
TestRunnerCompletedEvent,
} from '@/types/electron';
const logger = createLogger('TestRunners');
/**
* Options for starting a test run
*/
export interface StartTestOptions {
/** Project path to get test command from settings */
projectPath?: string;
/** Specific test file to run (runs all tests if not provided) */
testFile?: string;
}
/**
* Result from starting a test run
*/
export interface StartTestResult {
success: boolean;
sessionId?: string;
error?: string;
}
/**
* Result from stopping a test run
*/
export interface StopTestResult {
success: boolean;
error?: string;
}
/**
* Hook for managing test runners with full lifecycle support
*
* @param worktreePath - The worktree path to scope the hook to (optional for global event handling)
* @returns Test runner state and actions
*/
export function useTestRunners(worktreePath?: string) {
// Get store state and actions
const {
sessions,
activeSessionByWorktree,
isLoading,
error,
startSession,
appendOutput,
completeSession,
getActiveSession,
getSession,
isWorktreeRunning,
removeSession,
clearWorktreeSessions,
setLoading,
setError,
} = useTestRunnersStore(
useShallow((state) => ({
sessions: state.sessions,
activeSessionByWorktree: state.activeSessionByWorktree,
isLoading: state.isLoading,
error: state.error,
startSession: state.startSession,
appendOutput: state.appendOutput,
completeSession: state.completeSession,
getActiveSession: state.getActiveSession,
getSession: state.getSession,
isWorktreeRunning: state.isWorktreeRunning,
removeSession: state.removeSession,
clearWorktreeSessions: state.clearWorktreeSessions,
setLoading: state.setLoading,
setError: state.setError,
}))
);
// Derived state for the current worktree
const activeSession = useMemo(() => {
if (!worktreePath) return null;
return getActiveSession(worktreePath);
}, [worktreePath, getActiveSession, activeSessionByWorktree]);
const isRunning = useMemo(() => {
if (!worktreePath) return false;
return isWorktreeRunning(worktreePath);
}, [worktreePath, isWorktreeRunning, activeSessionByWorktree, sessions]);
// Get all sessions for the current worktree
const worktreeSessions = useMemo(() => {
if (!worktreePath) return [];
return Object.values(sessions).filter((s) => s.worktreePath === worktreePath);
}, [worktreePath, sessions]);
// Subscribe to test runner events
useEffect(() => {
const api = getElectronAPI();
if (!api?.worktree?.onTestRunnerEvent) {
logger.warn('Test runner event subscription not available');
return;
}
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
// If worktreePath is specified, only handle events for that worktree
if (worktreePath && event.payload.worktreePath !== worktreePath) {
return;
}
switch (event.type) {
case 'test-runner:started': {
const payload = event.payload as TestRunnerStartedEvent;
logger.info(`Test run started: ${payload.sessionId} in ${payload.worktreePath}`);
startSession({
sessionId: payload.sessionId,
worktreePath: payload.worktreePath,
command: payload.command,
status: 'running',
testFile: payload.testFile,
startedAt: payload.timestamp,
});
break;
}
case 'test-runner:output': {
const payload = event.payload as TestRunnerOutputEvent;
appendOutput(payload.sessionId, payload.content);
break;
}
case 'test-runner:completed': {
const payload = event.payload as TestRunnerCompletedEvent;
logger.info(
`Test run completed: ${payload.sessionId} with status ${payload.status} (exit code: ${payload.exitCode})`
);
completeSession(payload.sessionId, payload.status, payload.exitCode, payload.duration);
break;
}
}
});
return unsubscribe;
}, [worktreePath, startSession, appendOutput, completeSession]);
// Load existing test logs on mount (if worktreePath is provided)
useEffect(() => {
if (!worktreePath) return;
const loadExistingLogs = async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getTestLogs) return;
setLoading(true);
setError(null);
const result = await api.worktree.getTestLogs(worktreePath);
if (result.success && result.result) {
const { sessionId, 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 };