mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03: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:
420
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
420
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Terminal,
|
||||
ArrowDown,
|
||||
Square,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||
import { useTestLogs } from '@/hooks/use-test-logs';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import type { TestRunStatus } from '@/types/electron';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TestLogsPanelProps {
|
||||
/** Whether the panel is open */
|
||||
open: boolean;
|
||||
/** Callback when the panel is closed */
|
||||
onClose: () => void;
|
||||
/** Path to the worktree to show test logs for */
|
||||
worktreePath: string | null;
|
||||
/** Branch name for display */
|
||||
branch?: string;
|
||||
/** Specific session ID to fetch logs for (optional) */
|
||||
sessionId?: string;
|
||||
/** Callback to stop the running tests */
|
||||
onStopTests?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get status indicator based on test run status
|
||||
*/
|
||||
function getStatusIndicator(status: TestRunStatus | null): {
|
||||
text: string;
|
||||
className: string;
|
||||
icon?: React.ReactNode;
|
||||
} {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
text: 'Running',
|
||||
className: 'bg-blue-500/10 text-blue-500',
|
||||
icon: <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />,
|
||||
};
|
||||
case 'passed':
|
||||
return {
|
||||
text: 'Passed',
|
||||
className: 'bg-green-500/10 text-green-500',
|
||||
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
text: 'Failed',
|
||||
className: 'bg-red-500/10 text-red-500',
|
||||
icon: <XCircle className="w-3 h-3" />,
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
text: 'Cancelled',
|
||||
className: 'bg-yellow-500/10 text-yellow-500',
|
||||
icon: <AlertCircle className="w-3 h-3" />,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
text: 'Error',
|
||||
className: 'bg-red-500/10 text-red-500',
|
||||
icon: <AlertCircle className="w-3 h-3" />,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: 'Idle',
|
||||
className: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number | null): string | null {
|
||||
if (ms === null) return null;
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to localized time string
|
||||
*/
|
||||
function formatTime(timestamp: string | null): string | null {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inner Content Component
|
||||
// ============================================================================
|
||||
|
||||
interface TestLogsPanelContentProps {
|
||||
worktreePath: string | null;
|
||||
branch?: string;
|
||||
sessionId?: string;
|
||||
onStopTests?: () => void;
|
||||
}
|
||||
|
||||
function TestLogsPanelContent({
|
||||
worktreePath,
|
||||
branch,
|
||||
sessionId,
|
||||
onStopTests,
|
||||
}: TestLogsPanelContentProps) {
|
||||
const xtermRef = useRef<XtermLogViewerRef>(null);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const lastLogsLengthRef = useRef(0);
|
||||
const lastSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
error,
|
||||
status,
|
||||
sessionId: currentSessionId,
|
||||
command,
|
||||
testFile,
|
||||
startedAt,
|
||||
exitCode,
|
||||
duration,
|
||||
isRunning,
|
||||
fetchLogs,
|
||||
} = useTestLogs({
|
||||
worktreePath,
|
||||
sessionId,
|
||||
autoSubscribe: true,
|
||||
});
|
||||
|
||||
// Write logs to xterm when they change
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current || !logs) return;
|
||||
|
||||
// If session changed, reset the terminal and write all content
|
||||
if (lastSessionIdRef.current !== currentSessionId) {
|
||||
lastSessionIdRef.current = currentSessionId;
|
||||
lastLogsLengthRef.current = 0;
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// If logs got shorter (e.g., cleared), rewrite all
|
||||
if (logs.length < lastLogsLengthRef.current) {
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Append only the new content
|
||||
if (logs.length > lastLogsLengthRef.current) {
|
||||
const newContent = logs.slice(lastLogsLengthRef.current);
|
||||
xtermRef.current.append(newContent);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
}
|
||||
}, [logs, currentSessionId]);
|
||||
|
||||
// Reset auto-scroll when session changes
|
||||
useEffect(() => {
|
||||
if (currentSessionId !== lastSessionIdRef.current) {
|
||||
setAutoScrollEnabled(true);
|
||||
lastLogsLengthRef.current = 0;
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
xtermRef.current?.scrollToBottom();
|
||||
setAutoScrollEnabled(true);
|
||||
}, []);
|
||||
|
||||
const statusIndicator = getStatusIndicator(status);
|
||||
const formattedStartTime = formatTime(startedAt);
|
||||
const formattedDuration = formatDuration(duration);
|
||||
const lineCount = logs ? logs.split('\n').length : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FlaskConical className="w-4 h-4 text-primary" />
|
||||
<span>Test Runner</span>
|
||||
{status && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
statusIndicator.className
|
||||
)}
|
||||
>
|
||||
{statusIndicator.icon}
|
||||
{statusIndicator.text}
|
||||
</span>
|
||||
)}
|
||||
{formattedDuration && !isRunning && (
|
||||
<span className="text-xs text-muted-foreground font-mono">{formattedDuration}</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && onStopTests && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onStopTests}
|
||||
>
|
||||
<Square className="w-3 h-3 mr-1.5 fill-current" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => fetchLogs()}
|
||||
title="Refresh logs"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
{branch && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="font-medium text-foreground/80">{branch}</span>
|
||||
</span>
|
||||
)}
|
||||
{command && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground/60">Command</span>
|
||||
<span className="font-mono text-primary truncate max-w-[200px]">{command}</span>
|
||||
</span>
|
||||
)}
|
||||
{testFile && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground/60">File</span>
|
||||
<span className="font-mono truncate max-w-[150px]">{testFile}</span>
|
||||
</span>
|
||||
)}
|
||||
{formattedStartTime && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formattedStartTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Error displays */}
|
||||
{error && (
|
||||
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log content area */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-zinc-950" data-testid="test-logs-content">
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||
<Spinner size="md" className="mr-2" />
|
||||
<span className="text-sm">Loading logs...</span>
|
||||
</div>
|
||||
) : !logs && !isRunning && !status ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<Terminal className="w-10 h-10 mb-3 opacity-20" />
|
||||
<p className="text-sm">No test run active</p>
|
||||
<p className="text-xs mt-1 opacity-60">Start a test run to see logs here</p>
|
||||
</div>
|
||||
) : isRunning && !logs ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<Spinner size="xl" className="mb-3" />
|
||||
<p className="text-sm">Waiting for output...</p>
|
||||
<p className="text-xs mt-1 opacity-60">Logs will appear as tests generate output</p>
|
||||
</div>
|
||||
) : (
|
||||
<XtermLogViewer
|
||||
ref={xtermRef}
|
||||
className="h-full"
|
||||
minHeight={280}
|
||||
autoScroll={autoScrollEnabled}
|
||||
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer status bar */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
|
||||
{exitCode !== null && (
|
||||
<span className={cn('font-mono', exitCode === 0 ? 'text-green-500' : 'text-red-500')}>
|
||||
Exit: {exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!autoScrollEnabled && logs && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
{autoScrollEnabled && logs && (
|
||||
<span className="inline-flex items-center gap-1.5 opacity-60">
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Auto-scroll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Panel component for displaying test runner logs with ANSI color rendering
|
||||
* and real-time streaming support.
|
||||
*
|
||||
* Features:
|
||||
* - Real-time log streaming via WebSocket
|
||||
* - Full ANSI color code rendering via xterm.js
|
||||
* - Auto-scroll to bottom (can be paused by scrolling up)
|
||||
* - Test status indicators (pending, running, passed, failed, etc.)
|
||||
* - Dialog on desktop, Sheet on mobile
|
||||
* - Quick actions (stop tests, refresh logs)
|
||||
*/
|
||||
export function TestLogsPanel({
|
||||
open,
|
||||
onClose,
|
||||
worktreePath,
|
||||
branch,
|
||||
sessionId,
|
||||
onStopTests,
|
||||
}: TestLogsPanelProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!worktreePath) return null;
|
||||
|
||||
// Mobile: use Sheet (bottom drawer)
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<SheetContent side="bottom" className="h-[80vh] p-0 flex flex-col">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Test Logs</SheetTitle>
|
||||
</SheetHeader>
|
||||
<TestLogsPanelContent
|
||||
worktreePath={worktreePath}
|
||||
branch={branch}
|
||||
sessionId={sessionId}
|
||||
onStopTests={onStopTests}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: use Dialog
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
||||
data-testid="test-logs-panel"
|
||||
compact
|
||||
>
|
||||
<TestLogsPanelContent
|
||||
worktreePath={worktreePath}
|
||||
branch={branch}
|
||||
sessionId={sessionId}
|
||||
onStopTests={onStopTests}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -33,10 +33,11 @@ import {
|
||||
SplitSquareHorizontal,
|
||||
Undo2,
|
||||
Zap,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
import {
|
||||
@@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps {
|
||||
standalone?: boolean;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
/** Whether tests are being started for this worktree */
|
||||
isStartingTests?: boolean;
|
||||
/** Whether tests are currently running for this worktree */
|
||||
isTestRunning?: boolean;
|
||||
/** Active test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
@@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps {
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
/** Start running tests for this worktree */
|
||||
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||
/** Stop running tests for this worktree */
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({
|
||||
gitRepoStatus,
|
||||
standalone = false,
|
||||
isAutoModeRunning = false,
|
||||
hasTestCommand = false,
|
||||
isStartingTests = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
@@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onMerge,
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Test Runner section - only show when test command is configured */}
|
||||
{hasTestCommand && onStartTests && (
|
||||
<>
|
||||
{isTestRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
Tests Running
|
||||
</DropdownMenuLabel>
|
||||
{onViewTestLogs && (
|
||||
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Test Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onStopTests && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopTests(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-2" />
|
||||
Stop Tests
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStartTests(worktree)}
|
||||
disabled={isStartingTests}
|
||||
className="text-xs"
|
||||
>
|
||||
<FlaskConical
|
||||
className={cn('w-3.5 h-3.5 mr-2', isStartingTests && 'animate-pulse')}
|
||||
/>
|
||||
{isStartingTests ? 'Starting Tests...' : 'Run Tests'}
|
||||
</DropdownMenuItem>
|
||||
{onViewTestLogs && testSessionInfo && (
|
||||
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Last Test Results
|
||||
{testSessionInfo.status === 'passed' && (
|
||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded">
|
||||
passed
|
||||
</span>
|
||||
)}
|
||||
{testSessionInfo.status === 'failed' && (
|
||||
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 px-1.5 py-0.5 rounded">
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Auto Mode toggle */}
|
||||
{onToggleAutoMode && (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import type {
|
||||
WorktreeInfo,
|
||||
BranchInfo,
|
||||
DevServerInfo,
|
||||
PRInfo,
|
||||
GitRepoStatus,
|
||||
TestSessionInfo,
|
||||
} from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
|
||||
@@ -33,6 +40,12 @@ interface WorktreeTabProps {
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
/** Whether tests are being started for this worktree */
|
||||
isStartingTests?: boolean;
|
||||
/** Whether tests are currently running for this worktree */
|
||||
isTestRunning?: boolean;
|
||||
/** Active test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
@@ -59,7 +72,15 @@ interface WorktreeTabProps {
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
/** Start running tests for this worktree */
|
||||
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||
/** Stop running tests for this worktree */
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
@@ -85,6 +106,9 @@ export function WorktreeTab({
|
||||
hasRemoteBranch,
|
||||
gitRepoStatus,
|
||||
isAutoModeRunning = false,
|
||||
isStartingTests = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
@@ -111,7 +135,11 @@ export function WorktreeTab({
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
hasInitScript,
|
||||
hasTestCommand = false,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -395,6 +423,10 @@ export function WorktreeTab({
|
||||
devServerInfo={devServerInfo}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunning}
|
||||
testSessionInfo={testSessionInfo}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
@@ -416,6 +448,9 @@ export function WorktreeTab({
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
onToggleAutoMode={onToggleAutoMode}
|
||||
onStartTests={onStartTests}
|
||||
onStopTests={onStopTests}
|
||||
onViewTestLogs={onViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,19 @@ export interface DevServerInfo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TestSessionInfo {
|
||||
sessionId: string;
|
||||
worktreePath: string;
|
||||
/** The test command being run (from project settings) */
|
||||
command: string;
|
||||
status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
|
||||
testFile?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
exitCode?: number | null;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface FeatureInfo {
|
||||
id: string;
|
||||
branchName?: string;
|
||||
|
||||
@@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useWorktreeInitScript } from '@/hooks/queries';
|
||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||
import type {
|
||||
TestRunnerStartedEvent,
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
@@ -161,6 +169,191 @@ export function WorktreePanel({
|
||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||
const hasInitScript = initScriptData?.exists ?? false;
|
||||
|
||||
// Check if test command is configured in project settings
|
||||
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||
const hasTestCommand = !!projectSettings?.testCommand;
|
||||
|
||||
// Test runner state management
|
||||
// Use the test runners store to get global state for all worktrees
|
||||
const testRunnersStore = useTestRunnersStore();
|
||||
const [isStartingTests, setIsStartingTests] = useState(false);
|
||||
|
||||
// Subscribe to test runner events to update store state in real-time
|
||||
// This ensures the UI updates when tests start, output is received, or tests complete
|
||||
useTestRunnerEvents(
|
||||
// onStarted - a new test run has begun
|
||||
useCallback(
|
||||
(event: TestRunnerStartedEvent) => {
|
||||
testRunnersStore.startSession({
|
||||
sessionId: event.sessionId,
|
||||
worktreePath: event.worktreePath,
|
||||
command: event.command,
|
||||
status: 'running',
|
||||
testFile: event.testFile,
|
||||
startedAt: event.timestamp,
|
||||
});
|
||||
},
|
||||
[testRunnersStore]
|
||||
),
|
||||
// onOutput - test output received
|
||||
useCallback(
|
||||
(event: TestRunnerOutputEvent) => {
|
||||
testRunnersStore.appendOutput(event.sessionId, event.content);
|
||||
},
|
||||
[testRunnersStore]
|
||||
),
|
||||
// onCompleted - test run finished
|
||||
useCallback(
|
||||
(event: TestRunnerCompletedEvent) => {
|
||||
testRunnersStore.completeSession(
|
||||
event.sessionId,
|
||||
event.status,
|
||||
event.exitCode,
|
||||
event.duration
|
||||
);
|
||||
// Show toast notification for test completion
|
||||
const statusEmoji =
|
||||
event.status === 'passed' ? '✅' : event.status === 'failed' ? '❌' : '⏹️';
|
||||
const statusText =
|
||||
event.status === 'passed' ? 'passed' : event.status === 'failed' ? 'failed' : 'stopped';
|
||||
toast(`${statusEmoji} Tests ${statusText}`, {
|
||||
description: `Exit code: ${event.exitCode ?? 'N/A'}`,
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
[testRunnersStore]
|
||||
)
|
||||
);
|
||||
|
||||
// Test logs panel state
|
||||
const [testLogsPanelOpen, setTestLogsPanelOpen] = useState(false);
|
||||
const [testLogsPanelWorktree, setTestLogsPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Helper to check if tests are running for a specific worktree
|
||||
const isTestRunningForWorktree = useCallback(
|
||||
(worktree: WorktreeInfo): boolean => {
|
||||
return testRunnersStore.isWorktreeRunning(worktree.path);
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Helper to get test session info for a specific worktree
|
||||
const getTestSessionInfo = useCallback(
|
||||
(worktree: WorktreeInfo): TestSessionInfo | undefined => {
|
||||
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||
if (!session) {
|
||||
// Check for completed sessions to show last result
|
||||
const allSessions = Object.values(testRunnersStore.sessions).filter(
|
||||
(s) => s.worktreePath === worktree.path
|
||||
);
|
||||
const lastSession = allSessions.sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
)[0];
|
||||
if (lastSession) {
|
||||
return {
|
||||
sessionId: lastSession.sessionId,
|
||||
worktreePath: lastSession.worktreePath,
|
||||
command: lastSession.command,
|
||||
status: lastSession.status as TestSessionInfo['status'],
|
||||
testFile: lastSession.testFile,
|
||||
startedAt: lastSession.startedAt,
|
||||
finishedAt: lastSession.finishedAt,
|
||||
exitCode: lastSession.exitCode,
|
||||
duration: lastSession.duration,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: session.status as TestSessionInfo['status'],
|
||||
testFile: session.testFile,
|
||||
startedAt: session.startedAt,
|
||||
finishedAt: session.finishedAt,
|
||||
exitCode: session.exitCode,
|
||||
duration: session.duration,
|
||||
};
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Handler to start tests for a worktree
|
||||
const handleStartTests = useCallback(async (worktree: WorktreeInfo) => {
|
||||
setIsStartingTests(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.startTests) {
|
||||
toast.error('Test runner API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.startTests(worktree.path, { projectPath });
|
||||
if (result.success) {
|
||||
toast.success('Tests started', {
|
||||
description: `Running tests in ${worktree.branch}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to start tests', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to start tests', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsStartingTests(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handler to stop tests for a worktree
|
||||
const handleStopTests = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||
if (!session) {
|
||||
toast.error('No active test session to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.stopTests) {
|
||||
toast.error('Test runner API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.stopTests(session.sessionId);
|
||||
if (result.success) {
|
||||
toast.success('Tests stopped', {
|
||||
description: `Stopped tests in ${worktree.branch}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to stop tests', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to stop tests', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Handler to view test logs for a worktree
|
||||
const handleViewTestLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setTestLogsPanelWorktree(worktree);
|
||||
setTestLogsPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handler to close test logs panel
|
||||
const handleCloseTestLogsPanel = useCallback(() => {
|
||||
setTestLogsPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
// View changes dialog state
|
||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
@@ -392,6 +585,10 @@ export function WorktreePanel({
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
@@ -413,7 +610,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -494,6 +695,17 @@ export function WorktreePanel({
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Test Logs Panel */}
|
||||
<TestLogsPanel
|
||||
open={testLogsPanelOpen}
|
||||
onClose={handleCloseTestLogsPanel}
|
||||
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||
branch={testLogsPanelWorktree?.branch}
|
||||
onStopTests={
|
||||
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -530,6 +742,9 @@ export function WorktreePanel({
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
@@ -556,7 +771,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -596,6 +815,9 @@ export function WorktreePanel({
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
@@ -622,7 +844,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -703,6 +929,17 @@ export function WorktreePanel({
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Test Logs Panel */}
|
||||
<TestLogsPanel
|
||||
open={testLogsPanelOpen}
|
||||
onClose={handleCloseTestLogsPanel}
|
||||
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||
branch={testLogsPanelWorktree?.branch}
|
||||
onStopTests={
|
||||
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Workflow } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Workflow, FlaskConical } from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
@@ -11,6 +11,7 @@ export interface ProjectNavigationItem {
|
||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'testing', label: 'Testing', icon: FlaskConical },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
|
||||
export type ProjectSettingsViewId =
|
||||
| 'identity'
|
||||
| 'theme'
|
||||
| 'worktrees'
|
||||
| 'testing'
|
||||
| 'claude'
|
||||
| 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
initialView?: ProjectSettingsViewId;
|
||||
|
||||
@@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
|
||||
export { ProjectIdentitySection } from './project-identity-section';
|
||||
export { ProjectThemeSection } from './project-theme-section';
|
||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
export { TestingSection } from './testing-section';
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { TestingSection } from './testing-section';
|
||||
import { ProjectModelsSection } from './project-models-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
@@ -85,6 +86,8 @@ export function ProjectSettingsView() {
|
||||
return <ProjectThemeSection project={currentProject} />;
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'testing':
|
||||
return <TestingSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'danger':
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FlaskConical, Save, RotateCcw, Info } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface TestingSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function TestingSection({ project }: TestingSectionProps) {
|
||||
const [testCommand, setTestCommand] = useState('');
|
||||
const [originalTestCommand, setOriginalTestCommand] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = testCommand !== originalTestCommand;
|
||||
|
||||
// Load project settings when project changes
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
const currentPath = project.path;
|
||||
|
||||
const loadProjectSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const response = await httpClient.settings.getProject(currentPath);
|
||||
|
||||
// Avoid updating state if component unmounted or project changed
|
||||
if (isCancelled) return;
|
||||
|
||||
if (response.success && response.settings) {
|
||||
const command = response.settings.testCommand || '';
|
||||
setTestCommand(command);
|
||||
setOriginalTestCommand(command);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Failed to load project settings:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProjectSettings();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [project.path]);
|
||||
|
||||
// Save test command
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const response = await httpClient.settings.updateProject(project.path, {
|
||||
testCommand: testCommand.trim() || undefined,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setOriginalTestCommand(testCommand);
|
||||
toast.success('Test command saved');
|
||||
} else {
|
||||
toast.error('Failed to save test command', {
|
||||
description: response.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save test command:', error);
|
||||
toast.error('Failed to save test command');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [project.path, testCommand]);
|
||||
|
||||
// Reset to original value
|
||||
const handleReset = useCallback(() => {
|
||||
setTestCommand(originalTestCommand);
|
||||
}, [originalTestCommand]);
|
||||
|
||||
// Use a preset command
|
||||
const handleUsePreset = useCallback((command: string) => {
|
||||
setTestCommand(command);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<FlaskConical className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Testing Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure how tests are run for this project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Test Command Input */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="test-command" className="text-foreground font-medium">
|
||||
Test Command
|
||||
</Label>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-500 font-medium">(unsaved changes)</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="test-command"
|
||||
value={testCommand}
|
||||
onChange={(e) => setTestCommand(e.target.value)}
|
||||
placeholder="e.g., npm test, yarn test, pytest, go test ./..."
|
||||
className="font-mono text-sm"
|
||||
data-testid="test-command-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
The command to run tests for this project. If not specified, the test runner will
|
||||
auto-detect based on your project structure (package.json, Cargo.toml, go.mod,
|
||||
etc.).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-detection Info */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Auto-detection</p>
|
||||
<p>
|
||||
When no custom command is set, the test runner automatically detects and uses the
|
||||
appropriate test framework based on your project files (Vitest, Jest, Pytest,
|
||||
Cargo, Go Test, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Quick Presets</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: 'npm test', command: 'npm test' },
|
||||
{ label: 'yarn test', command: 'yarn test' },
|
||||
{ label: 'pnpm test', command: 'pnpm test' },
|
||||
{ label: 'bun test', command: 'bun test' },
|
||||
{ label: 'pytest', command: 'pytest' },
|
||||
{ label: 'cargo test', command: 'cargo test' },
|
||||
{ label: 'go test', command: 'go test ./...' },
|
||||
].map((preset) => (
|
||||
<Button
|
||||
key={preset.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUsePreset(preset.command)}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Click a preset to use it as your test command.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user