mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
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:
@@ -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}>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
272
apps/app/tests/utils/api/client.ts
Normal file
272
apps/app/tests/utils/api/client.ts
Normal 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);
|
||||||
|
}
|
||||||
188
apps/app/tests/utils/core/constants.ts
Normal file
188
apps/app/tests/utils/core/constants.ts
Normal 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;
|
||||||
366
apps/app/tests/utils/git/worktree.ts
Normal file
366
apps/app/tests/utils/git/worktree.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user