mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +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:
@@ -8,4 +8,18 @@ export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './
|
||||
export { useResponsiveKanban } from './use-responsive-kanban';
|
||||
export { useScrollTracking } from './use-scroll-tracking';
|
||||
export { useSettingsMigration } from './use-settings-migration';
|
||||
export {
|
||||
useTestRunners,
|
||||
useTestRunnerEvents,
|
||||
type StartTestOptions,
|
||||
type StartTestResult,
|
||||
type StopTestResult,
|
||||
type TestSession,
|
||||
} from './use-test-runners';
|
||||
export {
|
||||
useTestLogs,
|
||||
useTestLogEvents,
|
||||
type TestLogState,
|
||||
type UseTestLogsOptions,
|
||||
} from './use-test-logs';
|
||||
export { useWindowState } from './use-window-state';
|
||||
|
||||
383
apps/ui/src/hooks/use-test-logs.ts
Normal file
383
apps/ui/src/hooks/use-test-logs.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* useTestLogs - Hook for test log streaming and retrieval
|
||||
*
|
||||
* This hook provides a focused interface for:
|
||||
* - Fetching initial buffered test logs
|
||||
* - Subscribing to real-time log streaming
|
||||
* - Managing log state for display components
|
||||
*
|
||||
* Unlike useTestRunners, this hook focuses solely on log retrieval
|
||||
* and streaming, making it ideal for log display components.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import type {
|
||||
TestRunStatus,
|
||||
TestRunnerStartedEvent,
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
|
||||
const logger = createLogger('TestLogs');
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* State for test log management
|
||||
*/
|
||||
export interface TestLogState {
|
||||
/** The accumulated log content */
|
||||
logs: string;
|
||||
/** Whether initial logs are being fetched */
|
||||
isLoading: boolean;
|
||||
/** Error message if fetching logs failed */
|
||||
error: string | null;
|
||||
/** Current status of the test run */
|
||||
status: TestRunStatus | null;
|
||||
/** Session ID of the current test run */
|
||||
sessionId: string | null;
|
||||
/** The test command being run (from project settings) */
|
||||
command: string | null;
|
||||
/** Specific test file being run (if applicable) */
|
||||
testFile: string | null;
|
||||
/** Timestamp when the test run started */
|
||||
startedAt: string | null;
|
||||
/** Timestamp when the test run finished (if completed) */
|
||||
finishedAt: string | null;
|
||||
/** Exit code (if test run completed) */
|
||||
exitCode: number | null;
|
||||
/** Duration in milliseconds (if completed) */
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the useTestLogs hook
|
||||
*/
|
||||
export interface UseTestLogsOptions {
|
||||
/** Path to the worktree to monitor logs for */
|
||||
worktreePath: string | null;
|
||||
/** Specific session ID to fetch logs for (optional - will get active/recent if not provided) */
|
||||
sessionId?: string;
|
||||
/** Whether to automatically subscribe to log events (default: true) */
|
||||
autoSubscribe?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Initial State
|
||||
// ============================================================================
|
||||
|
||||
const initialState: TestLogState = {
|
||||
logs: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
status: null,
|
||||
sessionId: null,
|
||||
command: null,
|
||||
testFile: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
exitCode: null,
|
||||
duration: null,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to subscribe to test log events and manage log state.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Fetches initial buffered logs from the server
|
||||
* 2. Subscribes to WebSocket events for real-time log streaming
|
||||
* 3. Handles test runner started/output/completed events
|
||||
* 4. Provides log state for rendering in a panel
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { logs, status, isLoading, isRunning } = useTestLogs({
|
||||
* worktreePath: '/path/to/worktree'
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isLoading && <Spinner />}
|
||||
* {isRunning && <Badge>Running</Badge>}
|
||||
* <pre>{logs}</pre>
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useTestLogs({
|
||||
worktreePath,
|
||||
sessionId: targetSessionId,
|
||||
autoSubscribe = true,
|
||||
}: UseTestLogsOptions) {
|
||||
const [state, setState] = useState<TestLogState>(initialState);
|
||||
|
||||
// Keep track of whether we've fetched initial logs
|
||||
const hasFetchedInitialLogs = useRef(false);
|
||||
|
||||
// Track the current session ID for filtering events
|
||||
const currentSessionId = useRef<string | null>(targetSessionId ?? null);
|
||||
|
||||
/**
|
||||
* Derived state: whether tests are currently running
|
||||
*/
|
||||
const isRunning = state.status === 'running' || state.status === 'pending';
|
||||
|
||||
/**
|
||||
* Fetch buffered logs from the server
|
||||
*/
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!worktreePath && !targetSessionId) return;
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getTestLogs) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Test logs API not available',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.getTestLogs(worktreePath ?? undefined, targetSessionId);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const { sessionId, command, status, testFile, logs, startedAt, finishedAt, exitCode } =
|
||||
result.result;
|
||||
|
||||
// Update current session ID for event filtering
|
||||
currentSessionId.current = sessionId;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
status,
|
||||
sessionId,
|
||||
command,
|
||||
testFile: testFile ?? null,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
exitCode,
|
||||
duration: null, // Not provided by getTestLogs
|
||||
}));
|
||||
hasFetchedInitialLogs.current = true;
|
||||
} else {
|
||||
// No active session - this is not necessarily an error
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: result.error || null,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch test logs:', error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch logs',
|
||||
}));
|
||||
}
|
||||
}, [worktreePath, targetSessionId]);
|
||||
|
||||
/**
|
||||
* Clear logs and reset state
|
||||
*/
|
||||
const clearLogs = useCallback(() => {
|
||||
setState(initialState);
|
||||
hasFetchedInitialLogs.current = false;
|
||||
currentSessionId.current = targetSessionId ?? null;
|
||||
}, [targetSessionId]);
|
||||
|
||||
/**
|
||||
* Append content to logs
|
||||
*/
|
||||
const appendLogs = useCallback((content: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: prev.logs + content,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Fetch initial logs when worktreePath or sessionId changes
|
||||
useEffect(() => {
|
||||
if ((worktreePath || targetSessionId) && autoSubscribe) {
|
||||
hasFetchedInitialLogs.current = false;
|
||||
fetchLogs();
|
||||
} else {
|
||||
clearLogs();
|
||||
}
|
||||
}, [worktreePath, targetSessionId, autoSubscribe, fetchLogs, clearLogs]);
|
||||
|
||||
// Subscribe to WebSocket events
|
||||
useEffect(() => {
|
||||
if (!autoSubscribe) return;
|
||||
if (!worktreePath && !targetSessionId) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onTestRunnerEvent) {
|
||||
logger.warn('Test runner event subscription not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||
// Filter events based on worktree path or session ID
|
||||
const eventWorktreePath = event.payload.worktreePath;
|
||||
const eventSessionId = event.payload.sessionId;
|
||||
|
||||
// If we have a specific session ID target, only accept events for that session
|
||||
if (targetSessionId && eventSessionId !== targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a worktree path, filter by that
|
||||
if (worktreePath && !pathsEqual(eventWorktreePath, worktreePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'test-runner:started': {
|
||||
const payload = event.payload as TestRunnerStartedEvent;
|
||||
logger.info('Test run started:', payload);
|
||||
|
||||
// Update current session ID for future event filtering
|
||||
currentSessionId.current = payload.sessionId;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: 'running',
|
||||
sessionId: payload.sessionId,
|
||||
command: payload.command,
|
||||
testFile: payload.testFile ?? null,
|
||||
startedAt: payload.timestamp,
|
||||
finishedAt: null,
|
||||
exitCode: null,
|
||||
duration: null,
|
||||
// Clear logs on new test run start
|
||||
logs: '',
|
||||
error: null,
|
||||
}));
|
||||
hasFetchedInitialLogs.current = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'test-runner:output': {
|
||||
const payload = event.payload as TestRunnerOutputEvent;
|
||||
|
||||
// Only append if this is for our current session
|
||||
if (currentSessionId.current && payload.sessionId !== currentSessionId.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Append the new output to existing logs
|
||||
if (payload.content) {
|
||||
appendLogs(payload.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'test-runner:completed': {
|
||||
const payload = event.payload as TestRunnerCompletedEvent;
|
||||
logger.info('Test run completed:', payload);
|
||||
|
||||
// Only update if this is for our current session
|
||||
if (currentSessionId.current && payload.sessionId !== currentSessionId.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: payload.status,
|
||||
finishedAt: payload.timestamp,
|
||||
exitCode: payload.exitCode,
|
||||
duration: payload.duration,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [worktreePath, targetSessionId, autoSubscribe, appendLogs]);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Derived state
|
||||
/** Whether tests are currently running */
|
||||
isRunning,
|
||||
|
||||
// Actions
|
||||
/** Fetch/refresh logs from the server */
|
||||
fetchLogs,
|
||||
/** Clear logs and reset state */
|
||||
clearLogs,
|
||||
/** Manually append content to logs */
|
||||
appendLogs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subscribing to test log output events globally (across all sessions)
|
||||
*
|
||||
* Useful for notification systems or global log monitoring.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useTestLogEvents({
|
||||
* onOutput: (sessionId, content) => {
|
||||
* console.log(`[${sessionId}] ${content}`);
|
||||
* },
|
||||
* onCompleted: (sessionId, status) => {
|
||||
* toast(`Tests ${status}!`);
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useTestLogEvents(handlers: {
|
||||
onStarted?: (event: TestRunnerStartedEvent) => void;
|
||||
onOutput?: (event: TestRunnerOutputEvent) => void;
|
||||
onCompleted?: (event: TestRunnerCompletedEvent) => void;
|
||||
}) {
|
||||
const { onStarted, onOutput, onCompleted } = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onTestRunnerEvent) {
|
||||
logger.warn('Test runner event subscription not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||
switch (event.type) {
|
||||
case 'test-runner:started':
|
||||
onStarted?.(event.payload as TestRunnerStartedEvent);
|
||||
break;
|
||||
case 'test-runner:output':
|
||||
onOutput?.(event.payload as TestRunnerOutputEvent);
|
||||
break;
|
||||
case 'test-runner:completed':
|
||||
onCompleted?.(event.payload as TestRunnerCompletedEvent);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [onStarted, onOutput, onCompleted]);
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { TestRunStatus };
|
||||
393
apps/ui/src/hooks/use-test-runners.ts
Normal file
393
apps/ui/src/hooks/use-test-runners.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* useTestRunners - Hook for test runner lifecycle management
|
||||
*
|
||||
* This hook provides a complete interface for:
|
||||
* - Starting and stopping test runs
|
||||
* - Subscribing to test runner events (started, output, completed)
|
||||
* - Managing test session state per worktree
|
||||
* - Fetching existing test logs
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useTestRunnersStore, type TestSession } from '@/store/test-runners-store';
|
||||
import type {
|
||||
TestRunStatus,
|
||||
TestRunnerStartedEvent,
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
|
||||
const logger = createLogger('TestRunners');
|
||||
|
||||
/**
|
||||
* Options for starting a test run
|
||||
*/
|
||||
export interface StartTestOptions {
|
||||
/** Project path to get test command from settings */
|
||||
projectPath?: string;
|
||||
/** Specific test file to run (runs all tests if not provided) */
|
||||
testFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from starting a test run
|
||||
*/
|
||||
export interface StartTestResult {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from stopping a test run
|
||||
*/
|
||||
export interface StopTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing test runners with full lifecycle support
|
||||
*
|
||||
* @param worktreePath - The worktree path to scope the hook to (optional for global event handling)
|
||||
* @returns Test runner state and actions
|
||||
*/
|
||||
export function useTestRunners(worktreePath?: string) {
|
||||
// Get store state and actions
|
||||
const {
|
||||
sessions,
|
||||
activeSessionByWorktree,
|
||||
isLoading,
|
||||
error,
|
||||
startSession,
|
||||
appendOutput,
|
||||
completeSession,
|
||||
getActiveSession,
|
||||
getSession,
|
||||
isWorktreeRunning,
|
||||
removeSession,
|
||||
clearWorktreeSessions,
|
||||
setLoading,
|
||||
setError,
|
||||
} = useTestRunnersStore(
|
||||
useShallow((state) => ({
|
||||
sessions: state.sessions,
|
||||
activeSessionByWorktree: state.activeSessionByWorktree,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
startSession: state.startSession,
|
||||
appendOutput: state.appendOutput,
|
||||
completeSession: state.completeSession,
|
||||
getActiveSession: state.getActiveSession,
|
||||
getSession: state.getSession,
|
||||
isWorktreeRunning: state.isWorktreeRunning,
|
||||
removeSession: state.removeSession,
|
||||
clearWorktreeSessions: state.clearWorktreeSessions,
|
||||
setLoading: state.setLoading,
|
||||
setError: state.setError,
|
||||
}))
|
||||
);
|
||||
|
||||
// Derived state for the current worktree
|
||||
const activeSession = useMemo(() => {
|
||||
if (!worktreePath) return null;
|
||||
return getActiveSession(worktreePath);
|
||||
}, [worktreePath, getActiveSession, activeSessionByWorktree]);
|
||||
|
||||
const isRunning = useMemo(() => {
|
||||
if (!worktreePath) return false;
|
||||
return isWorktreeRunning(worktreePath);
|
||||
}, [worktreePath, isWorktreeRunning, activeSessionByWorktree, sessions]);
|
||||
|
||||
// Get all sessions for the current worktree
|
||||
const worktreeSessions = useMemo(() => {
|
||||
if (!worktreePath) return [];
|
||||
return Object.values(sessions).filter((s) => s.worktreePath === worktreePath);
|
||||
}, [worktreePath, sessions]);
|
||||
|
||||
// Subscribe to test runner events
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onTestRunnerEvent) {
|
||||
logger.warn('Test runner event subscription not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||
// If worktreePath is specified, only handle events for that worktree
|
||||
if (worktreePath && event.payload.worktreePath !== worktreePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'test-runner:started': {
|
||||
const payload = event.payload as TestRunnerStartedEvent;
|
||||
logger.info(`Test run started: ${payload.sessionId} in ${payload.worktreePath}`);
|
||||
|
||||
startSession({
|
||||
sessionId: payload.sessionId,
|
||||
worktreePath: payload.worktreePath,
|
||||
command: payload.command,
|
||||
status: 'running',
|
||||
testFile: payload.testFile,
|
||||
startedAt: payload.timestamp,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'test-runner:output': {
|
||||
const payload = event.payload as TestRunnerOutputEvent;
|
||||
appendOutput(payload.sessionId, payload.content);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'test-runner:completed': {
|
||||
const payload = event.payload as TestRunnerCompletedEvent;
|
||||
logger.info(
|
||||
`Test run completed: ${payload.sessionId} with status ${payload.status} (exit code: ${payload.exitCode})`
|
||||
);
|
||||
|
||||
completeSession(payload.sessionId, payload.status, payload.exitCode, payload.duration);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [worktreePath, startSession, appendOutput, completeSession]);
|
||||
|
||||
// Load existing test logs on mount (if worktreePath is provided)
|
||||
useEffect(() => {
|
||||
if (!worktreePath) return;
|
||||
|
||||
const loadExistingLogs = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getTestLogs) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await api.worktree.getTestLogs(worktreePath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const { sessionId, runner, status, testFile, logs, startedAt, finishedAt, exitCode } =
|
||||
result.result;
|
||||
|
||||
// Only add if we don't already have this session
|
||||
const existingSession = getSession(sessionId);
|
||||
if (!existingSession) {
|
||||
startSession({
|
||||
sessionId,
|
||||
worktreePath,
|
||||
runner,
|
||||
status,
|
||||
testFile,
|
||||
startedAt,
|
||||
finishedAt: finishedAt || undefined,
|
||||
exitCode: exitCode ?? undefined,
|
||||
});
|
||||
|
||||
// Add existing logs
|
||||
if (logs) {
|
||||
appendOutput(sessionId, logs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error loading test logs:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load test logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadExistingLogs();
|
||||
}, [worktreePath, setLoading, setError, getSession, startSession, appendOutput]);
|
||||
|
||||
// Start a test run
|
||||
const start = useCallback(
|
||||
async (options?: StartTestOptions): Promise<StartTestResult> => {
|
||||
if (!worktreePath) {
|
||||
return { success: false, error: 'No worktree path provided' };
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.startTests) {
|
||||
return { success: false, error: 'Test runner API not available' };
|
||||
}
|
||||
|
||||
logger.info(`Starting tests in ${worktreePath}`, options);
|
||||
|
||||
const result = await api.worktree.startTests(worktreePath, {
|
||||
projectPath: options?.projectPath,
|
||||
testFile: options?.testFile,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Failed to start tests:', result.error);
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
logger.info(`Tests started with session: ${result.result?.sessionId}`);
|
||||
return {
|
||||
success: true,
|
||||
sessionId: result.result?.sessionId,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error starting tests';
|
||||
logger.error('Error starting tests:', err);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
[worktreePath]
|
||||
);
|
||||
|
||||
// Stop a test run
|
||||
const stop = useCallback(
|
||||
async (sessionId?: string): Promise<StopTestResult> => {
|
||||
// Use provided sessionId or get the active session for this worktree
|
||||
const targetSessionId = sessionId || (worktreePath && activeSession?.sessionId);
|
||||
|
||||
if (!targetSessionId) {
|
||||
return { success: false, error: 'No active test session to stop' };
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.stopTests) {
|
||||
return { success: false, error: 'Test runner API not available' };
|
||||
}
|
||||
|
||||
logger.info(`Stopping test session: ${targetSessionId}`);
|
||||
|
||||
const result = await api.worktree.stopTests(targetSessionId);
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Failed to stop tests:', result.error);
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
logger.info('Tests stopped successfully');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error stopping tests';
|
||||
logger.error('Error stopping tests:', err);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
[worktreePath, activeSession]
|
||||
);
|
||||
|
||||
// Refresh logs for the current session
|
||||
const refreshLogs = useCallback(
|
||||
async (sessionId?: string): Promise<{ success: boolean; logs?: string; error?: string }> => {
|
||||
const targetSessionId = sessionId || (worktreePath && activeSession?.sessionId);
|
||||
|
||||
if (!targetSessionId && !worktreePath) {
|
||||
return { success: false, error: 'No session or worktree to refresh' };
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getTestLogs) {
|
||||
return { success: false, error: 'Test logs API not available' };
|
||||
}
|
||||
|
||||
const result = await api.worktree.getTestLogs(worktreePath, targetSessionId);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
logs: result.result?.logs,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error fetching logs';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
[worktreePath, activeSession]
|
||||
);
|
||||
|
||||
// Clear session history for the current worktree
|
||||
const clearHistory = useCallback(() => {
|
||||
if (worktreePath) {
|
||||
clearWorktreeSessions(worktreePath);
|
||||
}
|
||||
}, [worktreePath, clearWorktreeSessions]);
|
||||
|
||||
return {
|
||||
// State
|
||||
/** The currently active test session for this worktree */
|
||||
activeSession,
|
||||
/** Whether tests are currently running in this worktree */
|
||||
isRunning,
|
||||
/** All test sessions for this worktree (including completed) */
|
||||
sessions: worktreeSessions,
|
||||
/** Loading state */
|
||||
isLoading,
|
||||
/** Error state */
|
||||
error,
|
||||
|
||||
// Actions
|
||||
/** Start a test run */
|
||||
start,
|
||||
/** Stop a test run */
|
||||
stop,
|
||||
/** Refresh logs for a session */
|
||||
refreshLogs,
|
||||
/** Clear session history for this worktree */
|
||||
clearHistory,
|
||||
|
||||
// Lower-level access (for advanced use cases)
|
||||
/** Get a specific session by ID */
|
||||
getSession,
|
||||
/** Remove a specific session */
|
||||
removeSession,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subscribing to test runner events globally (across all worktrees)
|
||||
*
|
||||
* Useful for global status displays or notifications
|
||||
*/
|
||||
export function useTestRunnerEvents(
|
||||
onStarted?: (event: TestRunnerStartedEvent) => void,
|
||||
onOutput?: (event: TestRunnerOutputEvent) => void,
|
||||
onCompleted?: (event: TestRunnerCompletedEvent) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onTestRunnerEvent) {
|
||||
logger.warn('Test runner event subscription not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||
switch (event.type) {
|
||||
case 'test-runner:started':
|
||||
onStarted?.(event.payload as TestRunnerStartedEvent);
|
||||
break;
|
||||
case 'test-runner:output':
|
||||
onOutput?.(event.payload as TestRunnerOutputEvent);
|
||||
break;
|
||||
case 'test-runner:completed':
|
||||
onCompleted?.(event.payload as TestRunnerCompletedEvent);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [onStarted, onOutput, onCompleted]);
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { TestSession, TestRunStatus };
|
||||
Reference in New Issue
Block a user