mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +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 [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
|
||||
const hasWorktree = !!feature.branchName;
|
||||
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
|
||||
|
||||
const showSteps =
|
||||
kanbanCardDetailLevel === "standard" ||
|
||||
@@ -366,99 +364,63 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Tests (Manual) indicator badge */}
|
||||
{feature.skipTests && !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-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 && (
|
||||
{/* Status badges row */}
|
||||
{(feature.skipTests || feature.error || isJustFinished) && (
|
||||
<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"
|
||||
: 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"
|
||||
"absolute left-2 z-10 flex items-center gap-1",
|
||||
feature.priority ? "top-11" : "top-2"
|
||||
)}
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
title="Agent just finished working on this feature"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
{/* Skip Tests (Manual) indicator badge */}
|
||||
{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 */}
|
||||
{hasWorktree && !isCurrentAutoTask && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<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 cursor-default",
|
||||
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
|
||||
feature.priority
|
||||
? "top-11 left-2"
|
||||
: feature.error || feature.skipTests || isJustFinished
|
||||
? "top-8 left-2"
|
||||
: "top-2 left-2"
|
||||
)}
|
||||
data-testid={`branch-badge-${feature.id}`}
|
||||
>
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[300px]">
|
||||
<p className="font-mono text-xs break-all">
|
||||
{feature.branchName}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Error indicator badge */}
|
||||
{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-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
|
||||
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"
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
title="Agent just finished working on this feature"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader
|
||||
@@ -467,10 +429,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
feature.priority && "pt-12",
|
||||
!feature.priority &&
|
||||
(feature.skipTests || feature.error || isJustFinished) &&
|
||||
"pt-10",
|
||||
hasWorktree &&
|
||||
(feature.skipTests || feature.error || isJustFinished) &&
|
||||
"pt-14"
|
||||
"pt-10"
|
||||
)}
|
||||
>
|
||||
{isCurrentAutoTask && (
|
||||
@@ -669,7 +628,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
<CardContent className="p-3 pt-0">
|
||||
{/* Target Branch Display */}
|
||||
{feature.branchName && (
|
||||
{useWorktrees && feature.branchName && (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono truncate" title={feature.branchName}>
|
||||
|
||||
@@ -106,6 +106,7 @@ export function WorktreeSelector({
|
||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
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 (
|
||||
<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" />
|
||||
|
||||
@@ -108,8 +108,8 @@ export function AddFeatureDialog({
|
||||
"improve" | "technical" | "simplify" | "acceptance"
|
||||
>("improve");
|
||||
|
||||
// Get enhancement model from store
|
||||
const { enhancementModel } = useAppStore();
|
||||
// Get enhancement model and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
|
||||
// Sync defaults when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -358,22 +358,24 @@ export function AddFeatureDialog({
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={newFeature.branchName}
|
||||
onChange={(value) =>
|
||||
setNewFeature({ ...newFeature, branchName: value })
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
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
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={newFeature.branchName}
|
||||
onChange={(value) =>
|
||||
setNewFeature({ ...newFeature, branchName: value })
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
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
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
|
||||
@@ -99,8 +99,8 @@ export function EditFeatureDialog({
|
||||
>("improve");
|
||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||
|
||||
// Get enhancement model from store
|
||||
const { enhancementModel } = useAppStore();
|
||||
// Get enhancement model and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
@@ -338,33 +338,35 @@ export function EditFeatureDialog({
|
||||
data-testid="edit-feature-category"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={editingFeature.branchName ?? "main"}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
branchName: value,
|
||||
})
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid="edit-feature-branch"
|
||||
disabled={editingFeature.status !== "backlog"}
|
||||
/>
|
||||
{editingFeature.status !== "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Branch cannot be changed after work has started.
|
||||
</p>
|
||||
)}
|
||||
{editingFeature.status === "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Work will be done in this branch. A worktree will be created
|
||||
if needed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={editingFeature.branchName ?? "main"}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
branchName: value,
|
||||
})
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid="edit-feature-branch"
|
||||
disabled={editingFeature.status !== "backlog"}
|
||||
/>
|
||||
{editingFeature.status !== "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Branch cannot be changed after work has started.
|
||||
</p>
|
||||
)}
|
||||
{editingFeature.status === "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Work will be done in this branch. A worktree will be created
|
||||
if needed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
|
||||
@@ -764,13 +764,17 @@ export function useBoardActions({
|
||||
const featuresToStart = backlogFeatures.slice(0, 1);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
|
||||
const worktreePath = await getOrCreateWorktreeForFeature(feature);
|
||||
if (worktreePath) {
|
||||
await persistFeatureUpdate(feature.id, { worktreePath });
|
||||
// Only create worktrees if the feature is enabled
|
||||
let worktreePath: string | null = null;
|
||||
if (useWorktrees) {
|
||||
// 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
|
||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||
await handleStartImplementation({
|
||||
@@ -786,6 +790,7 @@ export function useBoardActions({
|
||||
persistFeatureUpdate,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
useWorktrees,
|
||||
]);
|
||||
|
||||
const handleDeleteAllVerified = useCallback(async () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useBoardDragDrop({
|
||||
onWorktreeCreated,
|
||||
}: UseBoardDragDropProps) {
|
||||
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.
|
||||
@@ -157,13 +157,17 @@ export function useBoardDragDrop({
|
||||
if (draggedFeature.status === "backlog") {
|
||||
// From backlog
|
||||
if (targetStatus === "in_progress") {
|
||||
// Get or create worktree based on the feature's assigned branch
|
||||
const worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
|
||||
if (worktreePath) {
|
||||
await persistFeatureUpdate(featureId, { worktreePath });
|
||||
// Only create worktrees if the feature is enabled
|
||||
let worktreePath: string | null = null;
|
||||
if (useWorktrees) {
|
||||
// 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
|
||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
|
||||
@@ -278,6 +282,7 @@ export function useBoardDragDrop({
|
||||
handleStartImplementation,
|
||||
getOrCreateWorktreeForFeature,
|
||||
onWorktreeCreated,
|
||||
useWorktrees,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -103,21 +103,20 @@ export function FeatureDefaultsSection({
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* 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
|
||||
id="use-worktrees"
|
||||
checked={useWorktrees}
|
||||
onCheckedChange={(checked) =>
|
||||
onUseWorktreesChange(checked === true)
|
||||
}
|
||||
disabled={true}
|
||||
className="mt-1"
|
||||
data-testid="use-worktrees-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
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" />
|
||||
Enable Git Worktree Isolation
|
||||
@@ -129,9 +128,6 @@ export function FeatureDefaultsSection({
|
||||
Creates isolated git branches for each feature. When disabled,
|
||||
agents work directly in the main project directory.
|
||||
</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>
|
||||
|
||||
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/interactions";
|
||||
export * from "./core/waiting";
|
||||
export * from "./core/constants";
|
||||
|
||||
// API utilities
|
||||
export * from "./api/client";
|
||||
|
||||
// Git utilities
|
||||
export * from "./git/worktree";
|
||||
|
||||
// Project utilities
|
||||
export * from "./project/setup";
|
||||
|
||||
@@ -110,3 +110,117 @@ export async function getDragHandleForFeature(
|
||||
): Promise<Locator> {
|
||||
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