mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23: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:
@@ -506,6 +506,7 @@ export interface ProjectAnalysis {
|
||||
// Terminal panel layout types (recursive for splits)
|
||||
export type TerminalPanelContent =
|
||||
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
|
||||
| { type: 'testRunner'; sessionId: string; size?: number; worktreePath: string }
|
||||
| {
|
||||
type: 'split';
|
||||
id: string; // Stable ID for React key stability
|
||||
@@ -543,6 +544,7 @@ export interface TerminalState {
|
||||
// Used to restore terminal layout structure when switching projects
|
||||
export type PersistedTerminalPanel =
|
||||
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
|
||||
| { type: 'testRunner'; size?: number; sessionId?: string; worktreePath?: string }
|
||||
| {
|
||||
type: 'split';
|
||||
id?: string; // Optional for backwards compatibility with older persisted layouts
|
||||
@@ -3171,7 +3173,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
targetId: string,
|
||||
targetDirection: 'horizontal' | 'vertical'
|
||||
): TerminalPanelContent => {
|
||||
if (node.type === 'terminal') {
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||
if (node.sessionId === targetId) {
|
||||
// Found the target - split it
|
||||
return {
|
||||
@@ -3196,7 +3198,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
node: TerminalPanelContent,
|
||||
targetDirection: 'horizontal' | 'vertical'
|
||||
): TerminalPanelContent => {
|
||||
if (node.type === 'terminal') {
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||
return {
|
||||
type: 'split',
|
||||
id: generateSplitId(),
|
||||
@@ -3204,7 +3206,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
panels: [{ ...node, size: 50 }, newTerminal],
|
||||
};
|
||||
}
|
||||
// If same direction, add to existing split
|
||||
// It's a split - if same direction, add to existing split
|
||||
if (node.direction === targetDirection) {
|
||||
const newSize = 100 / (node.panels.length + 1);
|
||||
return {
|
||||
@@ -3253,7 +3255,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Find which tab contains this session
|
||||
const findFirstTerminal = (node: TerminalPanelContent | null): string | null => {
|
||||
if (!node) return null;
|
||||
if (node.type === 'terminal') return node.sessionId;
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||
for (const panel of node.panels) {
|
||||
const found = findFirstTerminal(panel);
|
||||
if (found) return found;
|
||||
@@ -3262,7 +3264,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
};
|
||||
|
||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||
if (node.type === 'terminal') {
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||
return node.sessionId === sessionId ? null : node;
|
||||
}
|
||||
const newPanels: TerminalPanelContent[] = [];
|
||||
@@ -3321,6 +3323,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 };
|
||||
return node;
|
||||
}
|
||||
if (node.type === 'testRunner') {
|
||||
// testRunner panels don't participate in swapping
|
||||
return node;
|
||||
}
|
||||
return { ...node, panels: node.panels.map(swapInLayout) };
|
||||
};
|
||||
|
||||
@@ -3373,6 +3379,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}
|
||||
return node;
|
||||
}
|
||||
if (node.type === 'testRunner') {
|
||||
// testRunner panels don't have fontSize
|
||||
return node;
|
||||
}
|
||||
return { ...node, panels: node.panels.map(updateFontSize) };
|
||||
};
|
||||
|
||||
@@ -3486,7 +3496,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
if (newActiveTabId) {
|
||||
const newActiveTab = newTabs.find((t) => t.id === newActiveTabId);
|
||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||
if (node.type === 'terminal') return node.sessionId;
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||
for (const p of node.panels) {
|
||||
const f = findFirst(p);
|
||||
if (f) return f;
|
||||
@@ -3517,7 +3527,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
let newActiveSessionId = current.activeSessionId;
|
||||
if (tab.layout) {
|
||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||
if (node.type === 'terminal') return node.sessionId;
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||
for (const p of node.panels) {
|
||||
const f = findFirst(p);
|
||||
if (f) return f;
|
||||
@@ -3578,6 +3588,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
if (node.type === 'terminal') {
|
||||
return node.sessionId === sessionId ? node : null;
|
||||
}
|
||||
if (node.type === 'testRunner') {
|
||||
// testRunner panels don't participate in moveTerminalToTab
|
||||
return null;
|
||||
}
|
||||
for (const panel of node.panels) {
|
||||
const found = findTerminal(panel);
|
||||
if (found) return found;
|
||||
@@ -3602,7 +3616,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
if (!sourceTab?.layout) return;
|
||||
|
||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||
if (node.type === 'terminal') {
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||
return node.sessionId === sessionId ? null : node;
|
||||
}
|
||||
const newPanels: TerminalPanelContent[] = [];
|
||||
@@ -3663,7 +3677,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
size: 100,
|
||||
fontSize: originalTerminalNode.fontSize,
|
||||
};
|
||||
} else if (targetTab.layout.type === 'terminal') {
|
||||
} else if (targetTab.layout.type === 'terminal' || targetTab.layout.type === 'testRunner') {
|
||||
newTargetLayout = {
|
||||
type: 'split',
|
||||
id: generateSplitId(),
|
||||
@@ -3671,6 +3685,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
||||
};
|
||||
} else {
|
||||
// It's a split
|
||||
newTargetLayout = {
|
||||
...targetTab.layout,
|
||||
panels: [...targetTab.layout.panels, terminalNode],
|
||||
@@ -3713,7 +3728,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
if (!tab.layout) {
|
||||
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
|
||||
} else if (tab.layout.type === 'terminal') {
|
||||
} else if (tab.layout.type === 'terminal' || tab.layout.type === 'testRunner') {
|
||||
newLayout = {
|
||||
type: 'split',
|
||||
id: generateSplitId(),
|
||||
@@ -3721,6 +3736,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
||||
};
|
||||
} else {
|
||||
// It's a split
|
||||
if (tab.layout.direction === direction) {
|
||||
const newSize = 100 / (tab.layout.panels.length + 1);
|
||||
newLayout = {
|
||||
@@ -3761,7 +3777,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
// Find first terminal in layout if no activeSessionId provided
|
||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||
if (node.type === 'terminal') return node.sessionId;
|
||||
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||
for (const p of node.panels) {
|
||||
const found = findFirst(p);
|
||||
if (found) return found;
|
||||
@@ -3794,7 +3810,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
// Helper to generate panel key (matches getPanelKey in terminal-view.tsx)
|
||||
const getPanelKey = (panel: TerminalPanelContent): string => {
|
||||
if (panel.type === 'terminal') return panel.sessionId;
|
||||
if (panel.type === 'terminal' || panel.type === 'testRunner') return panel.sessionId;
|
||||
const childKeys = panel.panels.map(getPanelKey).join('-');
|
||||
return `split-${panel.direction}-${childKeys}`;
|
||||
};
|
||||
@@ -3804,7 +3820,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
const key = getPanelKey(panel);
|
||||
const newSize = sizeMap.get(key);
|
||||
|
||||
if (panel.type === 'terminal') {
|
||||
if (panel.type === 'terminal' || panel.type === 'testRunner') {
|
||||
return newSize !== undefined ? { ...panel, size: newSize } : panel;
|
||||
}
|
||||
|
||||
@@ -3847,6 +3863,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
branchName: panel.branchName, // Preserve branch name for display
|
||||
};
|
||||
}
|
||||
if (panel.type === 'testRunner') {
|
||||
return {
|
||||
type: 'testRunner',
|
||||
size: panel.size,
|
||||
sessionId: panel.sessionId, // Preserve for reconnection
|
||||
worktreePath: panel.worktreePath, // Preserve worktree context
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'split',
|
||||
id: panel.id, // Preserve stable ID
|
||||
|
||||
248
apps/ui/src/store/test-runners-store.ts
Normal file
248
apps/ui/src/store/test-runners-store.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Test Runners Store - State management for test runner sessions
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { TestRunStatus } from '@/types/electron';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A test run session
|
||||
*/
|
||||
export interface TestSession {
|
||||
/** Unique session ID */
|
||||
sessionId: string;
|
||||
/** Path to the worktree where tests are running */
|
||||
worktreePath: string;
|
||||
/** The test command being run (from project settings) */
|
||||
command: string;
|
||||
/** Current status of the test run */
|
||||
status: TestRunStatus;
|
||||
/** Optional: specific test file being run */
|
||||
testFile?: string;
|
||||
/** When the test run started */
|
||||
startedAt: string;
|
||||
/** When the test run finished (if completed) */
|
||||
finishedAt?: string;
|
||||
/** Exit code (if completed) */
|
||||
exitCode?: number | null;
|
||||
/** Duration in milliseconds (if completed) */
|
||||
duration?: number;
|
||||
/** Accumulated output logs */
|
||||
output: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Interface
|
||||
// ============================================================================
|
||||
|
||||
interface TestRunnersState {
|
||||
/** Map of sessionId -> TestSession for all tracked sessions */
|
||||
sessions: Record<string, TestSession>;
|
||||
/** Map of worktreePath -> sessionId for quick lookup of active session per worktree */
|
||||
activeSessionByWorktree: Record<string, string>;
|
||||
/** Loading state for initial data fetch */
|
||||
isLoading: boolean;
|
||||
/** Error state */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Actions Interface
|
||||
// ============================================================================
|
||||
|
||||
interface TestRunnersActions {
|
||||
/** Add or update a session when a test run starts */
|
||||
startSession: (session: Omit<TestSession, 'output'>) => void;
|
||||
|
||||
/** Append output to a session */
|
||||
appendOutput: (sessionId: string, content: string) => void;
|
||||
|
||||
/** Complete a session with final status */
|
||||
completeSession: (
|
||||
sessionId: string,
|
||||
status: TestRunStatus,
|
||||
exitCode: number | null,
|
||||
duration: number
|
||||
) => void;
|
||||
|
||||
/** Get the active session for a worktree */
|
||||
getActiveSession: (worktreePath: string) => TestSession | null;
|
||||
|
||||
/** Get a session by ID */
|
||||
getSession: (sessionId: string) => TestSession | null;
|
||||
|
||||
/** Check if a worktree has an active (running) test session */
|
||||
isWorktreeRunning: (worktreePath: string) => boolean;
|
||||
|
||||
/** Remove a session (cleanup) */
|
||||
removeSession: (sessionId: string) => void;
|
||||
|
||||
/** Clear all sessions for a worktree */
|
||||
clearWorktreeSessions: (worktreePath: string) => void;
|
||||
|
||||
/** Set loading state */
|
||||
setLoading: (loading: boolean) => void;
|
||||
|
||||
/** Set error state */
|
||||
setError: (error: string | null) => void;
|
||||
|
||||
/** Reset the store */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Initial State
|
||||
// ============================================================================
|
||||
|
||||
const initialState: TestRunnersState = {
|
||||
sessions: {},
|
||||
activeSessionByWorktree: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Store
|
||||
// ============================================================================
|
||||
|
||||
export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
startSession: (session) => {
|
||||
const newSession: TestSession = {
|
||||
...session,
|
||||
output: '',
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[session.sessionId]: newSession,
|
||||
},
|
||||
activeSessionByWorktree: {
|
||||
...state.activeSessionByWorktree,
|
||||
[session.worktreePath]: session.sessionId,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
appendOutput: (sessionId, content) => {
|
||||
set((state) => {
|
||||
const session = state.sessions[sessionId];
|
||||
if (!session) return state;
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[sessionId]: {
|
||||
...session,
|
||||
output: session.output + content,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
completeSession: (sessionId, status, exitCode, duration) => {
|
||||
set((state) => {
|
||||
const session = state.sessions[sessionId];
|
||||
if (!session) return state;
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
|
||||
// Remove from active sessions since it's no longer running
|
||||
const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[sessionId]: {
|
||||
...session,
|
||||
status,
|
||||
exitCode,
|
||||
duration,
|
||||
finishedAt,
|
||||
},
|
||||
},
|
||||
// Only remove from active if this is the current active session
|
||||
activeSessionByWorktree:
|
||||
state.activeSessionByWorktree[session.worktreePath] === sessionId
|
||||
? remainingActive
|
||||
: state.activeSessionByWorktree,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getActiveSession: (worktreePath) => {
|
||||
const state = get();
|
||||
const sessionId = state.activeSessionByWorktree[worktreePath];
|
||||
if (!sessionId) return null;
|
||||
return state.sessions[sessionId] || null;
|
||||
},
|
||||
|
||||
getSession: (sessionId) => {
|
||||
return get().sessions[sessionId] || null;
|
||||
},
|
||||
|
||||
isWorktreeRunning: (worktreePath) => {
|
||||
const state = get();
|
||||
const sessionId = state.activeSessionByWorktree[worktreePath];
|
||||
if (!sessionId) return false;
|
||||
const session = state.sessions[sessionId];
|
||||
return session?.status === 'running' || session?.status === 'pending';
|
||||
},
|
||||
|
||||
removeSession: (sessionId) => {
|
||||
set((state) => {
|
||||
const session = state.sessions[sessionId];
|
||||
if (!session) return state;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [sessionId]: _, ...remainingSessions } = state.sessions;
|
||||
|
||||
// Remove from active if this was the active session
|
||||
const { [session.worktreePath]: activeId, ...remainingActive } =
|
||||
state.activeSessionByWorktree;
|
||||
|
||||
return {
|
||||
sessions: remainingSessions,
|
||||
activeSessionByWorktree:
|
||||
activeId === sessionId ? remainingActive : state.activeSessionByWorktree,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearWorktreeSessions: (worktreePath) => {
|
||||
set((state) => {
|
||||
// Find all sessions for this worktree
|
||||
const sessionsToRemove = Object.values(state.sessions)
|
||||
.filter((s) => s.worktreePath === worktreePath)
|
||||
.map((s) => s.sessionId);
|
||||
|
||||
// Remove them from sessions
|
||||
const remainingSessions = { ...state.sessions };
|
||||
sessionsToRemove.forEach((id) => {
|
||||
delete remainingSessions[id];
|
||||
});
|
||||
|
||||
// Remove from active
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||
|
||||
return {
|
||||
sessions: remainingSessions,
|
||||
activeSessionByWorktree: remainingActive,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
Reference in New Issue
Block a user