mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +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:
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 };
|
||||
Reference in New Issue
Block a user