feat: enhance worktree functionality and UI integration

- Updated KanbanCard to conditionally display status badges based on feature attributes, improving visual feedback.
- Enhanced WorktreeSelector to conditionally render based on the worktree feature toggle, ensuring a cleaner UI when worktrees are disabled.
- Modified AddFeatureDialog and EditFeatureDialog to include branch selection only when worktrees are enabled, streamlining the feature creation process.
- Refactored useBoardActions and useBoardDragDrop hooks to create worktrees only when the feature is enabled, optimizing performance.
- Introduced comprehensive integration tests for worktree operations, ensuring robust functionality and error handling across various scenarios.
This commit is contained in:
Cody Seibert
2025-12-16 17:16:34 -05:00
parent f6a9ae6335
commit 176eeca096
13 changed files with 1588 additions and 522 deletions

View File

@@ -143,9 +143,7 @@ export const KanbanCard = memo(function KanbanCard({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null); const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now()); const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel } = useAppStore(); const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const hasWorktree = !!feature.branchName;
const showSteps = const showSteps =
kanbanCardDetailLevel === "standard" || kanbanCardDetailLevel === "standard" ||
@@ -366,99 +364,63 @@ export const KanbanCard = memo(function KanbanCard({
</div> </div>
)} )}
{/* Skip Tests (Manual) indicator badge */} {/* Status badges row */}
{feature.skipTests && !feature.error && ( {(feature.skipTests || feature.error || isJustFinished) && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
)}
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Error indicator badge */}
{feature.error && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished indicator badge */}
{isJustFinished && (
<div <div
className={cn( className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10", "absolute left-2 z-10 flex items-center gap-1",
feature.priority feature.priority ? "top-11" : "top-2"
? "top-11 left-2"
: feature.skipTests
? "top-8 left-2"
: "top-2 left-2",
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
"animate-pulse"
)} )}
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
> >
<Sparkles className="w-3 h-3" /> {/* Skip Tests (Manual) indicator badge */}
</div> {feature.skipTests && !feature.error && (
)} <TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Branch badge */} {/* Error indicator badge */}
{hasWorktree && !isCurrentAutoTask && ( {feature.error && (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={200}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className={cn( className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default", data-testid={`error-badge-${feature.id}`}
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]", >
feature.priority <AlertCircle className="w-3 h-3" />
? "top-11 left-2" </div>
: feature.error || feature.skipTests || isJustFinished </TooltipTrigger>
? "top-8 left-2" <TooltipContent side="right" className="text-xs max-w-[250px]">
: "top-2 left-2" <p>{feature.error}</p>
)} </TooltipContent>
data-testid={`branch-badge-${feature.id}`} </Tooltip>
> </TooltipProvider>
<GitBranch className="w-3 h-3 shrink-0" /> )}
</div>
</TooltipTrigger> {/* Just Finished indicator badge */}
<TooltipContent side="bottom" className="max-w-[300px]"> {isJustFinished && (
<p className="font-mono text-xs break-all"> <div
{feature.branchName} className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
</p> data-testid={`just-finished-badge-${feature.id}`}
</TooltipContent> title="Agent just finished working on this feature"
</Tooltip> >
</TooltipProvider> <Sparkles className="w-3 h-3" />
</div>
)}
</div>
)} )}
<CardHeader <CardHeader
@@ -467,10 +429,7 @@ export const KanbanCard = memo(function KanbanCard({
feature.priority && "pt-12", feature.priority && "pt-12",
!feature.priority && !feature.priority &&
(feature.skipTests || feature.error || isJustFinished) && (feature.skipTests || feature.error || isJustFinished) &&
"pt-10", "pt-10"
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
)} )}
> >
{isCurrentAutoTask && ( {isCurrentAutoTask && (
@@ -669,7 +628,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardContent className="p-3 pt-0"> <CardContent className="p-3 pt-0">
{/* Target Branch Display */} {/* Target Branch Display */}
{feature.branchName && ( {useWorktrees && feature.branchName && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground"> <div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<GitBranch className="w-3 h-3 shrink-0" /> <GitBranch className="w-3 h-3 shrink-0" />
<span className="font-mono truncate" title={feature.branchName}> <span className="font-mono truncate" title={feature.branchName}>

View File

@@ -106,6 +106,7 @@ export function WorktreeSelector({
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees); const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => { const fetchWorktrees = useCallback(async () => {
if (!projectPath) return; if (!projectPath) return;
@@ -780,6 +781,11 @@ export function WorktreeSelector({
); );
}; };
// Don't render the worktree selector if the feature is disabled
if (!useWorktreesEnabled) {
return null;
}
return ( return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm"> <div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" /> <GitBranch className="w-4 h-4 text-muted-foreground" />

View File

@@ -108,8 +108,8 @@ export function AddFeatureDialog({
"improve" | "technical" | "simplify" | "acceptance" "improve" | "technical" | "simplify" | "acceptance"
>("improve"); >("improve");
// Get enhancement model from store // Get enhancement model and worktrees setting from store
const { enhancementModel } = useAppStore(); const { enhancementModel, useWorktrees } = useAppStore();
// Sync defaults when dialog opens // Sync defaults when dialog opens
useEffect(() => { useEffect(() => {
@@ -358,22 +358,24 @@ export function AddFeatureDialog({
data-testid="feature-category-input" data-testid="feature-category-input"
/> />
</div> </div>
<div className="space-y-2"> {useWorktrees && (
<Label htmlFor="branch">Target Branch</Label> <div className="space-y-2">
<BranchAutocomplete <Label htmlFor="branch">Target Branch</Label>
value={newFeature.branchName} <BranchAutocomplete
onChange={(value) => value={newFeature.branchName}
setNewFeature({ ...newFeature, branchName: value }) onChange={(value) =>
} setNewFeature({ ...newFeature, branchName: value })
branches={branchSuggestions} }
placeholder="Select or create branch..." branches={branchSuggestions}
data-testid="feature-branch-input" placeholder="Select or create branch..."
/> data-testid="feature-branch-input"
<p className="text-xs text-muted-foreground"> />
Work will be done in this branch. A worktree will be created if <p className="text-xs text-muted-foreground">
needed. Work will be done in this branch. A worktree will be created if
</p> needed.
</div> </p>
</div>
)}
{/* Priority Selector */} {/* Priority Selector */}
<PrioritySelector <PrioritySelector

View File

@@ -99,8 +99,8 @@ export function EditFeatureDialog({
>("improve"); >("improve");
const [showDependencyTree, setShowDependencyTree] = useState(false); const [showDependencyTree, setShowDependencyTree] = useState(false);
// Get enhancement model from store // Get enhancement model and worktrees setting from store
const { enhancementModel } = useAppStore(); const { enhancementModel, useWorktrees } = useAppStore();
useEffect(() => { useEffect(() => {
setEditingFeature(feature); setEditingFeature(feature);
@@ -338,33 +338,35 @@ export function EditFeatureDialog({
data-testid="edit-feature-category" data-testid="edit-feature-category"
/> />
</div> </div>
<div className="space-y-2"> {useWorktrees && (
<Label htmlFor="edit-branch">Target Branch</Label> <div className="space-y-2">
<BranchAutocomplete <Label htmlFor="edit-branch">Target Branch</Label>
value={editingFeature.branchName ?? "main"} <BranchAutocomplete
onChange={(value) => value={editingFeature.branchName ?? "main"}
setEditingFeature({ onChange={(value) =>
...editingFeature, setEditingFeature({
branchName: value, ...editingFeature,
}) branchName: value,
} })
branches={branchSuggestions} }
placeholder="Select or create branch..." branches={branchSuggestions}
data-testid="edit-feature-branch" placeholder="Select or create branch..."
disabled={editingFeature.status !== "backlog"} data-testid="edit-feature-branch"
/> disabled={editingFeature.status !== "backlog"}
{editingFeature.status !== "backlog" && ( />
<p className="text-xs text-muted-foreground"> {editingFeature.status !== "backlog" && (
Branch cannot be changed after work has started. <p className="text-xs text-muted-foreground">
</p> Branch cannot be changed after work has started.
)} </p>
{editingFeature.status === "backlog" && ( )}
<p className="text-xs text-muted-foreground"> {editingFeature.status === "backlog" && (
Work will be done in this branch. A worktree will be created <p className="text-xs text-muted-foreground">
if needed. Work will be done in this branch. A worktree will be created
</p> if needed.
)} </p>
</div> )}
</div>
)}
{/* Priority Selector */} {/* Priority Selector */}
<PrioritySelector <PrioritySelector

View File

@@ -764,13 +764,17 @@ export function useBoardActions({
const featuresToStart = backlogFeatures.slice(0, 1); const featuresToStart = backlogFeatures.slice(0, 1);
for (const feature of featuresToStart) { for (const feature of featuresToStart) {
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress) // Only create worktrees if the feature is enabled
const worktreePath = await getOrCreateWorktreeForFeature(feature); let worktreePath: string | null = null;
if (worktreePath) { if (useWorktrees) {
await persistFeatureUpdate(feature.id, { worktreePath }); // Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
worktreePath = await getOrCreateWorktreeForFeature(feature);
if (worktreePath) {
await persistFeatureUpdate(feature.id, { worktreePath });
}
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
} }
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
// Start the implementation // Start the implementation
// Pass feature with worktreePath so handleRunFeature uses the correct path // Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ await handleStartImplementation({
@@ -786,6 +790,7 @@ export function useBoardActions({
persistFeatureUpdate, persistFeatureUpdate,
onWorktreeCreated, onWorktreeCreated,
currentWorktreeBranch, currentWorktreeBranch,
useWorktrees,
]); ]);
const handleDeleteAllVerified = useCallback(async () => { const handleDeleteAllVerified = useCallback(async () => {

View File

@@ -29,7 +29,7 @@ export function useBoardDragDrop({
onWorktreeCreated, onWorktreeCreated,
}: UseBoardDragDropProps) { }: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null); const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature } = useAppStore(); const { moveFeature, useWorktrees } = useAppStore();
/** /**
* Get or create the worktree path for a feature based on its branchName. * Get or create the worktree path for a feature based on its branchName.
@@ -157,13 +157,17 @@ export function useBoardDragDrop({
if (draggedFeature.status === "backlog") { if (draggedFeature.status === "backlog") {
// From backlog // From backlog
if (targetStatus === "in_progress") { if (targetStatus === "in_progress") {
// Get or create worktree based on the feature's assigned branch // Only create worktrees if the feature is enabled
const worktreePath = await getOrCreateWorktreeForFeature(draggedFeature); let worktreePath: string | null = null;
if (worktreePath) { if (useWorktrees) {
await persistFeatureUpdate(featureId, { worktreePath }); // Get or create worktree based on the feature's assigned branch
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
if (worktreePath) {
await persistFeatureUpdate(featureId, { worktreePath });
}
// Refresh worktree selector after moving to in_progress
onWorktreeCreated?.();
} }
// Always refresh worktree selector after moving to in_progress
onWorktreeCreated?.();
// Use helper function to handle concurrency check and start implementation // Use helper function to handle concurrency check and start implementation
// Pass feature with worktreePath so handleRunFeature uses the correct path // Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined }); await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
@@ -278,6 +282,7 @@ export function useBoardDragDrop({
handleStartImplementation, handleStartImplementation,
getOrCreateWorktreeForFeature, getOrCreateWorktreeForFeature,
onWorktreeCreated, onWorktreeCreated,
useWorktrees,
] ]
); );

View File

@@ -103,21 +103,20 @@ export function FeatureDefaultsSection({
<div className="border-t border-border/30" /> <div className="border-t border-border/30" />
{/* Worktree Isolation Setting */} {/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl transition-colors duration-200 -mx-3 opacity-60"> <div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox <Checkbox
id="use-worktrees" id="use-worktrees"
checked={useWorktrees} checked={useWorktrees}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true) onUseWorktreesChange(checked === true)
} }
disabled={true}
className="mt-1" className="mt-1"
data-testid="use-worktrees-checkbox" data-testid="use-worktrees-checkbox"
/> />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label <Label
htmlFor="use-worktrees" htmlFor="use-worktrees"
className="text-foreground font-medium flex items-center gap-2" className="text-foreground cursor-pointer font-medium flex items-center gap-2"
> >
<GitBranch className="w-4 h-4 text-brand-500" /> <GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation Enable Git Worktree Isolation
@@ -129,9 +128,6 @@ export function FeatureDefaultsSection({
Creates isolated git branches for each feature. When disabled, Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory. agents work directly in the main project directory.
</p> </p>
<p className="text-xs text-orange-500/80 leading-relaxed font-medium">
This feature is still under development and temporarily disabled.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,272 @@
/**
* API client utilities for making API calls in tests
* Provides type-safe wrappers around common API operations
*/
import { Page, APIResponse } from "@playwright/test";
import { API_ENDPOINTS } from "../core/constants";
// ============================================================================
// Types
// ============================================================================
export interface WorktreeInfo {
path: string;
branch: string;
isNew?: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export interface WorktreeListResponse {
success: boolean;
worktrees: WorktreeInfo[];
error?: string;
}
export interface WorktreeCreateResponse {
success: boolean;
worktree?: WorktreeInfo;
error?: string;
}
export interface WorktreeDeleteResponse {
success: boolean;
error?: string;
}
export interface CommitResult {
committed: boolean;
branch?: string;
commitHash?: string;
message?: string;
}
export interface CommitResponse {
success: boolean;
result?: CommitResult;
error?: string;
}
export interface SwitchBranchResult {
previousBranch: string;
currentBranch: string;
message: string;
}
export interface SwitchBranchResponse {
success: boolean;
result?: SwitchBranchResult;
error?: string;
code?: string;
}
export interface BranchInfo {
name: string;
isCurrent: boolean;
}
export interface ListBranchesResult {
currentBranch: string;
branches: BranchInfo[];
}
export interface ListBranchesResponse {
success: boolean;
result?: ListBranchesResult;
error?: string;
}
// ============================================================================
// Worktree API Client
// ============================================================================
export class WorktreeApiClient {
constructor(private page: Page) {}
/**
* Create a new worktree
*/
async create(
projectPath: string,
branchName: string,
baseBranch?: string
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.create, {
data: {
projectPath,
branchName,
baseBranch,
},
});
const data = await response.json();
return { response, data };
}
/**
* Delete a worktree
*/
async delete(
projectPath: string,
worktreePath: string,
deleteBranch: boolean = true
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.delete, {
data: {
projectPath,
worktreePath,
deleteBranch,
},
});
const data = await response.json();
return { response, data };
}
/**
* List all worktrees
*/
async list(
projectPath: string,
includeDetails: boolean = true
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.list, {
data: {
projectPath,
includeDetails,
},
});
const data = await response.json();
return { response, data };
}
/**
* Commit changes in a worktree
*/
async commit(
worktreePath: string,
message: string
): Promise<{ response: APIResponse; data: CommitResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.commit, {
data: {
worktreePath,
message,
},
});
const data = await response.json();
return { response, data };
}
/**
* Switch branches in a worktree
*/
async switchBranch(
worktreePath: string,
branchName: string
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.switchBranch, {
data: {
worktreePath,
branchName,
},
});
const data = await response.json();
return { response, data };
}
/**
* List all branches
*/
async listBranches(
worktreePath: string
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.listBranches, {
data: {
worktreePath,
},
});
const data = await response.json();
return { response, data };
}
}
// ============================================================================
// Factory Functions
// ============================================================================
/**
* Create a WorktreeApiClient instance
*/
export function createWorktreeApiClient(page: Page): WorktreeApiClient {
return new WorktreeApiClient(page);
}
// ============================================================================
// Convenience Functions (for direct use without creating a client)
// ============================================================================
/**
* Create a worktree via API
*/
export async function apiCreateWorktree(
page: Page,
projectPath: string,
branchName: string,
baseBranch?: string
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
return new WorktreeApiClient(page).create(projectPath, branchName, baseBranch);
}
/**
* Delete a worktree via API
*/
export async function apiDeleteWorktree(
page: Page,
projectPath: string,
worktreePath: string,
deleteBranch: boolean = true
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
return new WorktreeApiClient(page).delete(projectPath, worktreePath, deleteBranch);
}
/**
* List worktrees via API
*/
export async function apiListWorktrees(
page: Page,
projectPath: string,
includeDetails: boolean = true
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
return new WorktreeApiClient(page).list(projectPath, includeDetails);
}
/**
* Commit changes in a worktree via API
*/
export async function apiCommitWorktree(
page: Page,
worktreePath: string,
message: string
): Promise<{ response: APIResponse; data: CommitResponse }> {
return new WorktreeApiClient(page).commit(worktreePath, message);
}
/**
* Switch branches in a worktree via API
*/
export async function apiSwitchBranch(
page: Page,
worktreePath: string,
branchName: string
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
return new WorktreeApiClient(page).switchBranch(worktreePath, branchName);
}
/**
* List branches via API
*/
export async function apiListBranches(
page: Page,
worktreePath: string
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
return new WorktreeApiClient(page).listBranches(worktreePath);
}

View File

@@ -0,0 +1,188 @@
/**
* Centralized constants for test utilities
* This file contains all shared constants like URLs, timeouts, and selectors
*/
// ============================================================================
// API Configuration
// ============================================================================
/**
* Base URL for the API server
*/
export const API_BASE_URL = "http://localhost:3008";
/**
* API endpoints for worktree operations
*/
export const API_ENDPOINTS = {
worktree: {
create: `${API_BASE_URL}/api/worktree/create`,
delete: `${API_BASE_URL}/api/worktree/delete`,
list: `${API_BASE_URL}/api/worktree/list`,
commit: `${API_BASE_URL}/api/worktree/commit`,
switchBranch: `${API_BASE_URL}/api/worktree/switch-branch`,
listBranches: `${API_BASE_URL}/api/worktree/list-branches`,
status: `${API_BASE_URL}/api/worktree/status`,
revert: `${API_BASE_URL}/api/worktree/revert`,
info: `${API_BASE_URL}/api/worktree/info`,
},
fs: {
browse: `${API_BASE_URL}/api/fs/browse`,
read: `${API_BASE_URL}/api/fs/read`,
write: `${API_BASE_URL}/api/fs/write`,
},
features: {
list: `${API_BASE_URL}/api/features/list`,
create: `${API_BASE_URL}/api/features/create`,
update: `${API_BASE_URL}/api/features/update`,
delete: `${API_BASE_URL}/api/features/delete`,
},
} as const;
// ============================================================================
// Timeout Configuration
// ============================================================================
/**
* Default timeouts in milliseconds
*/
export const TIMEOUTS = {
/** Default timeout for element visibility checks */
default: 5000,
/** Short timeout for quick checks */
short: 2000,
/** Medium timeout for standard operations */
medium: 10000,
/** Long timeout for slow operations */
long: 30000,
/** Extra long timeout for very slow operations */
extraLong: 60000,
/** Timeout for animations to complete */
animation: 300,
/** Small delay for UI to settle */
settle: 500,
/** Delay for network operations */
network: 1000,
} as const;
// ============================================================================
// Test ID Selectors
// ============================================================================
/**
* Common data-testid selectors organized by component/view
*/
export const TEST_IDS = {
// Sidebar & Navigation
sidebar: "sidebar",
navBoard: "nav-board",
navSpec: "nav-spec",
navContext: "nav-context",
navAgent: "nav-agent",
navProfiles: "nav-profiles",
settingsButton: "settings-button",
openProjectButton: "open-project-button",
// Views
boardView: "board-view",
specView: "spec-view",
contextView: "context-view",
agentView: "agent-view",
profilesView: "profiles-view",
settingsView: "settings-view",
welcomeView: "welcome-view",
setupView: "setup-view",
// Board View Components
addFeatureButton: "add-feature-button",
addFeatureDialog: "add-feature-dialog",
confirmAddFeature: "confirm-add-feature",
featureBranchInput: "feature-branch-input",
featureCategoryInput: "feature-category-input",
worktreeSelector: "worktree-selector",
// Spec Editor
specEditor: "spec-editor",
// File Browser Dialog
pathInput: "path-input",
goToPathButton: "go-to-path-button",
// Profiles View
addProfileButton: "add-profile-button",
addProfileDialog: "add-profile-dialog",
editProfileDialog: "edit-profile-dialog",
deleteProfileConfirmDialog: "delete-profile-confirm-dialog",
saveProfileButton: "save-profile-button",
confirmDeleteProfileButton: "confirm-delete-profile-button",
cancelDeleteButton: "cancel-delete-button",
profileNameInput: "profile-name-input",
profileDescriptionInput: "profile-description-input",
refreshProfilesButton: "refresh-profiles-button",
// Context View
contextFileList: "context-file-list",
addContextButton: "add-context-button",
} as const;
// ============================================================================
// CSS Selectors
// ============================================================================
/**
* Common CSS selectors for elements that don't have data-testid
*/
export const CSS_SELECTORS = {
/** CodeMirror editor content area */
codeMirrorContent: ".cm-content",
/** Dialog elements */
dialog: '[role="dialog"]',
/** Sonner toast notifications */
toast: "[data-sonner-toast]",
toastError: '[data-sonner-toast][data-type="error"]',
toastSuccess: '[data-sonner-toast][data-type="success"]',
/** Command/combobox input (shadcn-ui cmdk) */
commandInput: "[cmdk-input]",
/** Radix dialog overlay */
dialogOverlay: "[data-radix-dialog-overlay]",
} as const;
// ============================================================================
// Storage Keys
// ============================================================================
/**
* localStorage keys used by the application
*/
export const STORAGE_KEYS = {
appStorage: "automaker-storage",
setupStorage: "automaker-setup",
} as const;
// ============================================================================
// Branch Name Utilities
// ============================================================================
/**
* Sanitize a branch name to create a valid worktree directory name
* @param branchName - The branch name to sanitize
* @returns Sanitized name suitable for directory paths
*/
export function sanitizeBranchName(branchName: string): string {
return branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
}
// ============================================================================
// Default Values
// ============================================================================
/**
* Default values used in test setup
*/
export const DEFAULTS = {
projectName: "Test Project",
projectPath: "/mock/test-project",
theme: "dark" as const,
maxConcurrency: 3,
} as const;

View File

@@ -0,0 +1,366 @@
/**
* Git worktree utilities for testing
* Provides helpers for creating test git repos and managing worktrees
*/
import * as fs from "fs";
import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { Page } from "@playwright/test";
import { sanitizeBranchName, TIMEOUTS } from "../core/constants";
const execAsync = promisify(exec);
// ============================================================================
// Types
// ============================================================================
export interface TestRepo {
path: string;
cleanup: () => Promise<void>;
}
export interface FeatureData {
id: string;
category: string;
description: string;
status: string;
branchName?: string;
worktreePath?: string;
}
// ============================================================================
// Path Utilities
// ============================================================================
/**
* Get the workspace root directory (internal use only)
* Note: Also exported from project/fixtures.ts for broader use
*/
function getWorkspaceRoot(): string {
const cwd = process.cwd();
if (cwd.includes("apps/app")) {
return path.resolve(cwd, "../..");
}
return cwd;
}
/**
* Create a unique temp directory path for tests
*/
export function createTempDirPath(prefix: string = "temp-worktree-tests"): string {
const uniqueId = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`;
return path.join(getWorkspaceRoot(), "test", `${prefix}-${uniqueId}`);
}
/**
* Get the expected worktree path for a branch
*/
export function getWorktreePath(projectPath: string, branchName: string): string {
const sanitizedName = sanitizeBranchName(branchName);
return path.join(projectPath, ".worktrees", sanitizedName);
}
// ============================================================================
// Git Repository Management
// ============================================================================
/**
* Create a temporary git repository for testing
*/
export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
// Create temp directory if it doesn't exist
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tmpDir = path.join(tempDir, `test-repo-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Initialize git repo
await execAsync("git init", { cwd: tmpDir });
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
// Create initial commit
fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n");
await execAsync("git add .", { cwd: tmpDir });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
// Create main branch explicitly
await execAsync("git branch -M main", { cwd: tmpDir });
// Create .automaker directories
const automakerDir = path.join(tmpDir, ".automaker");
const featuresDir = path.join(automakerDir, "features");
fs.mkdirSync(featuresDir, { recursive: true });
return {
path: tmpDir,
cleanup: async () => {
await cleanupTestRepo(tmpDir);
},
};
}
/**
* Cleanup a test git repository
*/
export async function cleanupTestRepo(repoPath: string): Promise<void> {
try {
// Remove all worktrees first
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: repoPath,
}).catch(() => ({ stdout: "" }));
const worktrees = stdout
.split("\n\n")
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
})
.filter(Boolean);
for (const worktreePath of worktrees) {
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: repoPath,
});
} catch {
// Ignore errors
}
}
// Remove the repository
fs.rmSync(repoPath, { recursive: true, force: true });
} catch (error) {
console.error("Failed to cleanup test repo:", error);
}
}
/**
* Cleanup a temp directory and all its contents
*/
export function cleanupTempDir(tempDir: string): void {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
// ============================================================================
// Git Operations
// ============================================================================
/**
* Execute a git command in a repository
*/
export async function gitExec(
repoPath: string,
command: string
): Promise<{ stdout: string; stderr: string }> {
return execAsync(`git ${command}`, { cwd: repoPath });
}
/**
* Get list of git worktrees
*/
export async function listWorktrees(repoPath: string): Promise<string[]> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: repoPath,
});
return stdout
.split("\n\n")
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
})
.filter(Boolean) as string[];
} catch {
return [];
}
}
/**
* Get list of git branches
*/
export async function listBranches(repoPath: string): Promise<string[]> {
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
return stdout
.split("\n")
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
.filter(Boolean);
}
/**
* Get the current branch name
*/
export async function getCurrentBranch(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath });
return stdout.trim();
}
/**
* Create a git branch
*/
export async function createBranch(repoPath: string, branchName: string): Promise<void> {
await execAsync(`git branch ${branchName}`, { cwd: repoPath });
}
/**
* Checkout a git branch
*/
export async function checkoutBranch(repoPath: string, branchName: string): Promise<void> {
await execAsync(`git checkout ${branchName}`, { cwd: repoPath });
}
/**
* Create a git worktree using git command directly
*/
export async function createWorktreeDirectly(
repoPath: string,
branchName: string,
worktreePath?: string
): Promise<string> {
const sanitizedName = sanitizeBranchName(branchName);
const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName);
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
return targetPath;
}
/**
* Add and commit a file
*/
export async function commitFile(
repoPath: string,
filePath: string,
content: string,
message: string
): Promise<void> {
fs.writeFileSync(path.join(repoPath, filePath), content);
await execAsync(`git add "${filePath}"`, { cwd: repoPath });
await execAsync(`git commit -m "${message}"`, { cwd: repoPath });
}
/**
* Get the latest commit message
*/
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath });
return stdout.trim();
}
// ============================================================================
// Feature File Management
// ============================================================================
/**
* Create a feature file in the test repo
*/
export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void {
const featuresDir = path.join(repoPath, ".automaker", "features");
const featureDir = path.join(featuresDir, featureId);
fs.mkdirSync(featureDir, { recursive: true });
fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(featureData, null, 2));
}
/**
* Read a feature file from the test repo
*/
export function readTestFeature(repoPath: string, featureId: string): FeatureData | null {
const featureFilePath = path.join(repoPath, ".automaker", "features", featureId, "feature.json");
if (!fs.existsSync(featureFilePath)) {
return null;
}
return JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
}
/**
* List all feature directories in the test repo
*/
export function listTestFeatures(repoPath: string): string[] {
const featuresDir = path.join(repoPath, ".automaker", "features");
if (!fs.existsSync(featuresDir)) {
return [];
}
return fs.readdirSync(featuresDir);
}
// ============================================================================
// Project Setup for Tests
// ============================================================================
/**
* Set up localStorage with a project pointing to a test repo
*/
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-worktree",
name: "Worktree Test Project",
path: pathArg,
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
}, projectPath);
}
// ============================================================================
// Wait Utilities
// ============================================================================
/**
* Wait for the board view to load
*/
export async function waitForBoardView(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="board-view"]', { timeout: TIMEOUTS.long });
}
/**
* Wait for the worktree selector to be visible
*/
export async function waitForWorktreeSelector(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => {
// Fallback: wait for "Branch:" text
return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium });
});
}

View File

@@ -4,6 +4,13 @@
export * from "./core/elements"; export * from "./core/elements";
export * from "./core/interactions"; export * from "./core/interactions";
export * from "./core/waiting"; export * from "./core/waiting";
export * from "./core/constants";
// API utilities
export * from "./api/client";
// Git utilities
export * from "./git/worktree";
// Project utilities // Project utilities
export * from "./project/setup"; export * from "./project/setup";

View File

@@ -110,3 +110,117 @@ export async function getDragHandleForFeature(
): Promise<Locator> { ): Promise<Locator> {
return page.locator(`[data-testid="drag-handle-${featureId}"]`); return page.locator(`[data-testid="drag-handle-${featureId}"]`);
} }
// ============================================================================
// Add Feature Dialog
// ============================================================================
/**
* Click the add feature button
*/
export async function clickAddFeature(page: Page): Promise<void> {
await page.click('[data-testid="add-feature-button"]');
await page.waitForSelector('[data-testid="add-feature-dialog"]', { timeout: 5000 });
}
/**
* Fill in the add feature dialog
*/
export async function fillAddFeatureDialog(
page: Page,
description: string,
options?: { branch?: string; category?: string }
): Promise<void> {
// Fill description (using the dropzone textarea)
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
await descriptionInput.fill(description);
// Fill branch if provided (it's a combobox autocomplete)
if (options?.branch) {
const branchButton = page.locator('[data-testid="feature-branch-input"]');
await branchButton.click();
// Wait for the popover to open
await page.waitForTimeout(300);
// Type in the command input
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.branch);
// Press Enter to select/create the branch
await commandInput.press("Enter");
// Wait for popover to close
await page.waitForTimeout(200);
}
// Fill category if provided (it's also a combobox autocomplete)
if (options?.category) {
const categoryButton = page.locator('[data-testid="feature-category-input"]');
await categoryButton.click();
await page.waitForTimeout(300);
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.category);
await commandInput.press("Enter");
await page.waitForTimeout(200);
}
}
/**
* Confirm the add feature dialog
*/
export async function confirmAddFeature(page: Page): Promise<void> {
await page.click('[data-testid="confirm-add-feature"]');
// Wait for dialog to close
await page.waitForFunction(
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
{ timeout: 5000 }
);
}
/**
* Add a feature with all steps in one call
*/
export async function addFeature(
page: Page,
description: string,
options?: { branch?: string; category?: string }
): Promise<void> {
await clickAddFeature(page);
await fillAddFeatureDialog(page, description, options);
await confirmAddFeature(page);
}
// ============================================================================
// Worktree Selector
// ============================================================================
/**
* Get the worktree selector element
*/
export async function getWorktreeSelector(page: Page): Promise<Locator> {
return page.locator('[data-testid="worktree-selector"]');
}
/**
* Click on a branch button in the worktree selector
*/
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> {
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
await branchButton.click();
await page.waitForTimeout(500); // Wait for UI to update
}
/**
* Get the currently selected branch in the worktree selector
*/
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
// The main branch button has aria-pressed="true" when selected
const selectedButton = page.locator('[data-testid="worktree-selector"] button[aria-pressed="true"]');
const text = await selectedButton.textContent().catch(() => null);
return text?.trim() || null;
}
/**
* Check if a branch button is visible in the worktree selector
*/
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> {
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
return await branchButton.isVisible().catch(() => false);
}

File diff suppressed because it is too large Load Diff