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

@@ -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