diff --git a/apps/app/src/components/views/board-view/components/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx index 30699154..2257ccbc 100644 --- a/apps/app/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/app/src/components/views/board-view/components/kanban-card.tsx @@ -143,9 +143,7 @@ export const KanbanCard = memo(function KanbanCard({ const [agentInfo, setAgentInfo] = useState(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({ )} - {/* Skip Tests (Manual) indicator badge */} - {feature.skipTests && !feature.error && ( - - - -
- -
-
- -

Manual verification required

-
-
-
- )} - - {/* Error indicator badge */} - {feature.error && ( - - - -
- -
-
- -

{feature.error}

-
-
-
- )} - - {/* Just Finished indicator badge */} - {isJustFinished && ( + {/* Status badges row */} + {(feature.skipTests || feature.error || isJustFinished) && (
- -
- )} + {/* Skip Tests (Manual) indicator badge */} + {feature.skipTests && !feature.error && ( + + + +
+ +
+
+ +

Manual verification required

+
+
+
+ )} - {/* Branch badge */} - {hasWorktree && !isCurrentAutoTask && ( - - - -
- -
-
- -

- {feature.branchName} -

-
-
-
+ {/* Error indicator badge */} + {feature.error && ( + + + +
+ +
+
+ +

{feature.error}

+
+
+
+ )} + + {/* Just Finished indicator badge */} + {isJustFinished && ( +
+ +
+ )} + )} {isCurrentAutoTask && ( @@ -669,7 +628,7 @@ export const KanbanCard = memo(function KanbanCard({ {/* Target Branch Display */} - {feature.branchName && ( + {useWorktrees && feature.branchName && (
diff --git a/apps/app/src/components/views/board-view/components/worktree-selector.tsx b/apps/app/src/components/views/board-view/components/worktree-selector.tsx index 84135acb..3cd49fe9 100644 --- a/apps/app/src/components/views/board-view/components/worktree-selector.tsx +++ b/apps/app/src/components/views/board-view/components/worktree-selector.tsx @@ -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 (
diff --git a/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 2b1916da..ef73b370 100644 --- a/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -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" />
-
- - - setNewFeature({ ...newFeature, branchName: value }) - } - branches={branchSuggestions} - placeholder="Select or create branch..." - data-testid="feature-branch-input" - /> -

- Work will be done in this branch. A worktree will be created if - needed. -

-
+ {useWorktrees && ( +
+ + + setNewFeature({ ...newFeature, branchName: value }) + } + branches={branchSuggestions} + placeholder="Select or create branch..." + data-testid="feature-branch-input" + /> +

+ Work will be done in this branch. A worktree will be created if + needed. +

+
+ )} {/* Priority Selector */} ("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" />
-
- - - setEditingFeature({ - ...editingFeature, - branchName: value, - }) - } - branches={branchSuggestions} - placeholder="Select or create branch..." - data-testid="edit-feature-branch" - disabled={editingFeature.status !== "backlog"} - /> - {editingFeature.status !== "backlog" && ( -

- Branch cannot be changed after work has started. -

- )} - {editingFeature.status === "backlog" && ( -

- Work will be done in this branch. A worktree will be created - if needed. -

- )} -
+ {useWorktrees && ( +
+ + + setEditingFeature({ + ...editingFeature, + branchName: value, + }) + } + branches={branchSuggestions} + placeholder="Select or create branch..." + data-testid="edit-feature-branch" + disabled={editingFeature.status !== "backlog"} + /> + {editingFeature.status !== "backlog" && ( +

+ Branch cannot be changed after work has started. +

+ )} + {editingFeature.status === "backlog" && ( +

+ Work will be done in this branch. A worktree will be created + if needed. +

+ )} +
+ )} {/* Priority Selector */} { diff --git a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts index 7eddbf90..e9016a8e 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -29,7 +29,7 @@ export function useBoardDragDrop({ onWorktreeCreated, }: UseBoardDragDropProps) { const [activeFeature, setActiveFeature] = useState(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, ] ); diff --git a/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index cd060466..4f2dc8e5 100644 --- a/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -103,21 +103,20 @@ export function FeatureDefaultsSection({
{/* Worktree Isolation Setting */} -
+
onUseWorktreesChange(checked === true) } - disabled={true} className="mt-1" data-testid="use-worktrees-checkbox" />
diff --git a/apps/app/tests/utils/api/client.ts b/apps/app/tests/utils/api/client.ts new file mode 100644 index 00000000..186e0bef --- /dev/null +++ b/apps/app/tests/utils/api/client.ts @@ -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); +} diff --git a/apps/app/tests/utils/core/constants.ts b/apps/app/tests/utils/core/constants.ts new file mode 100644 index 00000000..cd1a49b3 --- /dev/null +++ b/apps/app/tests/utils/core/constants.ts @@ -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; diff --git a/apps/app/tests/utils/git/worktree.ts b/apps/app/tests/utils/git/worktree.ts new file mode 100644 index 00000000..8a065b62 --- /dev/null +++ b/apps/app/tests/utils/git/worktree.ts @@ -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; +} + +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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + await execAsync(`git branch ${branchName}`, { cwd: repoPath }); +} + +/** + * Checkout a git branch + */ +export async function checkoutBranch(repoPath: string, branchName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => { + // Fallback: wait for "Branch:" text + return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium }); + }); +} diff --git a/apps/app/tests/utils/index.ts b/apps/app/tests/utils/index.ts index b2e4f088..68b2e066 100644 --- a/apps/app/tests/utils/index.ts +++ b/apps/app/tests/utils/index.ts @@ -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"; diff --git a/apps/app/tests/utils/views/board.ts b/apps/app/tests/utils/views/board.ts index a941aa3a..6295c77b 100644 --- a/apps/app/tests/utils/views/board.ts +++ b/apps/app/tests/utils/views/board.ts @@ -110,3 +110,117 @@ export async function getDragHandleForFeature( ): Promise { return page.locator(`[data-testid="drag-handle-${featureId}"]`); } + +// ============================================================================ +// Add Feature Dialog +// ============================================================================ + +/** + * Click the add feature button + */ +export async function clickAddFeature(page: Page): Promise { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") }); + return await branchButton.isVisible().catch(() => false); +} diff --git a/apps/app/tests/worktree-integration.spec.ts b/apps/app/tests/worktree-integration.spec.ts index f568a417..56fac5fb 100644 --- a/apps/app/tests/worktree-integration.spec.ts +++ b/apps/app/tests/worktree-integration.spec.ts @@ -1,273 +1,57 @@ +/** + * Worktree Integration Tests + * + * Tests for git worktree functionality including: + * - Creating and deleting worktrees + * - Committing changes + * - Switching branches + * - Branch listing + * - Worktree isolation + * - Feature filtering by worktree + */ + import { test, expect } from "@playwright/test"; 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 shared utilities +import { + waitForNetworkIdle, + apiCreateWorktree, + apiDeleteWorktree, + apiListWorktrees, + apiCommitWorktree, + apiSwitchBranch, + apiListBranches, + createTestGitRepo, + cleanupTempDir, + createTempDirPath, + getWorktreePath, + listWorktrees, + listBranches, + setupProjectWithPath, + waitForBoardView, + clickAddFeature, + fillAddFeatureDialog, + confirmAddFeature, +} from "./utils"; const execAsync = promisify(exec); -// Get workspace root for test fixture path -function getWorkspaceRoot(): string { - const cwd = process.cwd(); - if (cwd.includes("apps/app")) { - return path.resolve(cwd, "../.."); - } - return cwd; -} +// ============================================================================ +// Test Setup +// ============================================================================ -const WORKSPACE_ROOT = getWorkspaceRoot(); -// Use a unique temp dir based on process ID and random string to avoid collisions -const UNIQUE_ID = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`; -const TEST_TEMP_DIR = path.join(WORKSPACE_ROOT, "test", `temp-worktree-tests-${UNIQUE_ID}`); +// Create unique temp dir for this test run +const TEST_TEMP_DIR = createTempDirPath("worktree-tests"); interface TestRepo { path: string; cleanup: () => Promise; } -/** - * Create a temporary git repository for testing - */ -async function createTestGitRepo(): Promise { - // Create temp directory if it doesn't exist - if (!fs.existsSync(TEST_TEMP_DIR)) { - fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); - } - - const tmpDir = path.join(TEST_TEMP_DIR, `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 () => { - try { - // Remove all worktrees first - const { stdout } = await execAsync("git worktree list --porcelain", { - cwd: tmpDir, - }).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: tmpDir, - }); - } catch { - // Ignore errors - } - } - - // Remove the repository - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch (error) { - console.error("Failed to cleanup test repo:", error); - } - }, - }; -} - -/** - * Create a feature file in the test repo - */ -function createTestFeature( - repoPath: string, - featureId: string, - featureData: { - id: string; - category: string; - description: string; - status: string; - branchName?: string; - worktreePath?: string; - } -): 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) - ); -} - -/** - * Get list of git worktrees - */ -async function listWorktrees(repoPath: string): Promise { - 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 - */ -async function listBranches(repoPath: string): Promise { - const { stdout } = await execAsync("git branch --list", { cwd: repoPath }); - return stdout - .split("\n") - .map((line) => line.trim().replace(/^[*+]\s*/, "")) - .filter(Boolean); -} - -/** - * Set up localStorage with a project pointing to our test repo - */ -async function setupProjectWithPath(page: Page, projectPath: string): Promise { - 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 for network to be idle - */ -async function waitForNetworkIdle(page: Page): Promise { - await page.waitForLoadState("networkidle"); -} - -/** - * Wait for the board view to load - */ -async function waitForBoardView(page: Page): Promise { - await page.waitForSelector('[data-testid="board-view"]', { timeout: 30000 }); -} - -/** - * Click the add feature button - */ -async function clickAddFeature(page: Page): Promise { - await page.click('[data-testid="add-feature-button"]'); - await page.waitForSelector('[data-testid="add-feature-dialog"]', { timeout: 5000 }); -} - -/** - * Fill in the add feature dialog - */ -async function fillAddFeatureDialog( - page: Page, - description: string, - options?: { branch?: string; category?: string } -): Promise { - // 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 - */ -async function confirmAddFeature(page: Page): Promise { - 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 } - ); -} - // Configure all tests to run serially to prevent interference test.describe.configure({ mode: "serial" }); @@ -286,7 +70,7 @@ test.describe("Worktree Integration Tests", () => { test.beforeEach(async () => { // Create a fresh test repo for each test - testRepo = await createTestGitRepo(); + testRepo = await createTestGitRepo(TEST_TEMP_DIR); }); test.afterEach(async () => { @@ -298,18 +82,20 @@ test.describe("Worktree Integration Tests", () => { test.afterAll(async () => { // Cleanup temp directory - if (fs.existsSync(TEST_TEMP_DIR)) { - fs.rmSync(TEST_TEMP_DIR, { recursive: true, force: true }); - } + cleanupTempDir(TEST_TEMP_DIR); }); + // ========================================================================== + // Basic Worktree Operations + // ========================================================================== + test("should display worktree selector with main branch", async ({ page }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); - // Verify the worktree selector is visible - look for the "Branch:" label + // Verify the worktree selector is visible const branchLabel = page.getByText("Branch:"); await expect(branchLabel).toBeVisible({ timeout: 10000 }); @@ -324,29 +110,16 @@ test.describe("Worktree Integration Tests", () => { await waitForNetworkIdle(page); await waitForBoardView(page); - // Create worktree via API directly (simulating the dialog action) const branchName = "feature/test-worktree"; - const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); - const expectedWorktreePath = path.join(testRepo.path, ".worktrees", sanitizedName); + const expectedWorktreePath = getWorktreePath(testRepo.path, branchName); - // Make the API call directly through the server - const response = await page.request.post("http://localhost:3008/api/worktree/create", { - data: { - projectPath: testRepo.path, - branchName: branchName, - }, - headers: { - "Content-Type": "application/json", - }, - }); + const { response, data } = await apiCreateWorktree(page, testRepo.path, branchName); expect(response.ok()).toBe(true); - const result = await response.json(); - expect(result.success).toBe(true); + expect(data.success).toBe(true); // Verify worktree was created on filesystem - const worktreeExists = fs.existsSync(expectedWorktreePath); - expect(worktreeExists).toBe(true); + expect(fs.existsSync(expectedWorktreePath)).toBe(true); // Verify branch was created const branches = await listBranches(testRepo.path); @@ -365,24 +138,14 @@ test.describe("Worktree Integration Tests", () => { await waitForBoardView(page); // Create first worktree - const response1 = await page.request.post("http://localhost:3008/api/worktree/create", { - data: { - projectPath: testRepo.path, - branchName: "feature/worktree-one", - }, - }); + const { response: response1 } = await apiCreateWorktree(page, testRepo.path, "feature/worktree-one"); expect(response1.ok()).toBe(true); // Create second worktree - const response2 = await page.request.post("http://localhost:3008/api/worktree/create", { - data: { - projectPath: testRepo.path, - branchName: "feature/worktree-two", - }, - }); + const { response: response2 } = await apiCreateWorktree(page, testRepo.path, "feature/worktree-two"); expect(response2.ok()).toBe(true); - // Verify both worktrees exist on filesystem + // Verify both worktrees exist const worktrees = await listWorktrees(testRepo.path); expect(worktrees.length).toBe(2); @@ -398,31 +161,16 @@ test.describe("Worktree Integration Tests", () => { await waitForNetworkIdle(page); await waitForBoardView(page); - // First create a worktree + // Create a worktree const branchName = "feature/to-delete"; - const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); - const worktreePath = path.join(testRepo.path, ".worktrees", sanitizedName); + const worktreePath = getWorktreePath(testRepo.path, branchName); - const createResponse = await page.request.post("http://localhost:3008/api/worktree/create", { - data: { - projectPath: testRepo.path, - branchName: branchName, - }, - }); - expect(createResponse.ok()).toBe(true); - - // Verify it was created + await apiCreateWorktree(page, testRepo.path, branchName); expect(fs.existsSync(worktreePath)).toBe(true); - // Now delete it - const deleteResponse = await page.request.post("http://localhost:3008/api/worktree/delete", { - data: { - projectPath: testRepo.path, - worktreePath: worktreePath, - deleteBranch: true, - }, - }); - expect(deleteResponse.ok()).toBe(true); + // Delete it + const { response } = await apiDeleteWorktree(page, testRepo.path, worktreePath, true); + expect(response.ok()).toBe(true); // Verify worktree directory is removed expect(fs.existsSync(worktreePath)).toBe(false); @@ -432,6 +180,400 @@ test.describe("Worktree Integration Tests", () => { expect(branches).not.toContain(branchName); }); + test("should delete worktree but keep branch when deleteBranch is false", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/keep-branch"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + expect(fs.existsSync(worktreePath)).toBe(true); + + // Delete worktree but keep branch + const { response } = await apiDeleteWorktree(page, testRepo.path, worktreePath, false); + expect(response.ok()).toBe(true); + + // Verify worktree is gone but branch remains + expect(fs.existsSync(worktreePath)).toBe(false); + const branches = await listBranches(testRepo.path); + expect(branches).toContain(branchName); + }); + + test("should list worktrees via API", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Create some worktrees first + await apiCreateWorktree(page, testRepo.path, "feature/list-test-1"); + await apiCreateWorktree(page, testRepo.path, "feature/list-test-2"); + + // List worktrees via API + const { response, data } = await apiListWorktrees(page, testRepo.path, true); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + expect(data.worktrees).toHaveLength(3); // main + 2 worktrees + + // Verify worktree details + const branches = data.worktrees.map((w) => w.branch); + expect(branches).toContain("main"); + expect(branches).toContain("feature/list-test-1"); + expect(branches).toContain("feature/list-test-2"); + }); + + // ========================================================================== + // Commit Operations + // ========================================================================== + + test("should commit changes in a worktree via API", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/commit-test"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + const { response: createResponse } = await apiCreateWorktree(page, testRepo.path, branchName); + expect(createResponse.ok()).toBe(true); + + // Create a new file in the worktree + const testFilePath = path.join(worktreePath, "test-commit.txt"); + fs.writeFileSync(testFilePath, "This is a test file for commit"); + + // Commit the changes via API + const { response, data } = await apiCommitWorktree(page, worktreePath, "Add test file for commit integration test"); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + expect(data.result?.committed).toBe(true); + expect(data.result?.branch).toBe(branchName); + expect(data.result?.commitHash).toBeDefined(); + expect(data.result?.commitHash?.length).toBe(8); + + // Verify the commit exists in git log + const { stdout: logOutput } = await execAsync("git log --oneline -1", { cwd: worktreePath }); + expect(logOutput).toContain("Add test file for commit integration test"); + }); + + test("should return no changes when committing with no modifications", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/no-changes-commit"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Try to commit without any changes + const { response, data } = await apiCommitWorktree(page, worktreePath, "Empty commit attempt"); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + expect(data.result?.committed).toBe(false); + expect(data.result?.message).toBe("No changes to commit"); + }); + + test("should handle multiple sequential commits in a worktree", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/multi-commit"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // First commit + fs.writeFileSync(path.join(worktreePath, "file1.txt"), "First file"); + const { data: data1 } = await apiCommitWorktree(page, worktreePath, "First commit"); + expect(data1.result?.committed).toBe(true); + + // Second commit + fs.writeFileSync(path.join(worktreePath, "file2.txt"), "Second file"); + const { data: data2 } = await apiCommitWorktree(page, worktreePath, "Second commit"); + expect(data2.result?.committed).toBe(true); + + // Third commit + fs.writeFileSync(path.join(worktreePath, "file3.txt"), "Third file"); + const { data: data3 } = await apiCommitWorktree(page, worktreePath, "Third commit"); + expect(data3.result?.committed).toBe(true); + + // Verify all commits exist in log + const { stdout: logOutput } = await execAsync("git log --oneline -5", { cwd: worktreePath }); + expect(logOutput).toContain("First commit"); + expect(logOutput).toContain("Second commit"); + expect(logOutput).toContain("Third commit"); + }); + + // ========================================================================== + // Branch Switching + // ========================================================================== + + test("should switch branches within a worktree via API", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a second branch in the main repo for switching + await execAsync("git branch test-switch-target", { cwd: testRepo.path }); + + // Switch to the new branch via API + const { response, data } = await apiSwitchBranch(page, testRepo.path, "test-switch-target"); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + expect(data.result?.previousBranch).toBe("main"); + expect(data.result?.currentBranch).toBe("test-switch-target"); + expect(data.result?.message).toContain("Switched to branch"); + + // Verify the branch was actually switched + const { stdout: currentBranch } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: testRepo.path }); + expect(currentBranch.trim()).toBe("test-switch-target"); + + // Switch back to main + await execAsync("git checkout main", { cwd: testRepo.path }); + }); + + test("should prevent branch switch with uncommitted changes", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a branch to switch to + await execAsync("git branch test-switch-blocked", { cwd: testRepo.path }); + + // Create uncommitted changes + const testFilePath = path.join(testRepo.path, "uncommitted-change.txt"); + fs.writeFileSync(testFilePath, "This file has uncommitted changes"); + await execAsync("git add uncommitted-change.txt", { cwd: testRepo.path }); + + // Try to switch branches (should fail) + const { response, data } = await apiSwitchBranch(page, testRepo.path, "test-switch-blocked"); + + expect(response.ok()).toBe(false); + expect(data.success).toBe(false); + expect(data.error).toContain("uncommitted changes"); + expect(data.code).toBe("UNCOMMITTED_CHANGES"); + + // Clean up - reset changes + await execAsync("git reset HEAD", { cwd: testRepo.path }); + fs.unlinkSync(testFilePath); + }); + + test("should handle switching to non-existent branch", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Try to switch to a branch that doesn't exist + const { response, data } = await apiSwitchBranch(page, testRepo.path, "non-existent-branch"); + + expect(response.ok()).toBe(false); + expect(data.success).toBe(false); + expect(data.error).toContain("does not exist"); + }); + + test("should handle switching to current branch (no-op)", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Try to switch to the current branch + const { response, data } = await apiSwitchBranch(page, testRepo.path, "main"); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + expect(data.result?.message).toContain("Already on branch"); + }); + + // ========================================================================== + // List Branches + // ========================================================================== + + test("should list all branches via API", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create additional branches + await execAsync("git branch feature/branch-list-1", { cwd: testRepo.path }); + await execAsync("git branch feature/branch-list-2", { cwd: testRepo.path }); + await execAsync("git branch bugfix/test-branch", { cwd: testRepo.path }); + + // List branches via API + const { response, data } = await apiListBranches(page, testRepo.path); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + expect(data.result?.currentBranch).toBe("main"); + expect(data.result?.branches.length).toBeGreaterThanOrEqual(4); + + const branchNames = data.result?.branches.map((b) => b.name) || []; + expect(branchNames).toContain("main"); + expect(branchNames).toContain("feature/branch-list-1"); + expect(branchNames).toContain("feature/branch-list-2"); + expect(branchNames).toContain("bugfix/test-branch"); + + // Verify current branch is marked correctly + const currentBranchInfo = data.result?.branches.find((b) => b.name === "main"); + expect(currentBranchInfo?.isCurrent).toBe(true); + }); + + // ========================================================================== + // Worktree Isolation + // ========================================================================== + + test("should isolate files between worktrees", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create two worktrees + const branch1 = "feature/isolation-1"; + const branch2 = "feature/isolation-2"; + const worktree1Path = getWorktreePath(testRepo.path, branch1); + const worktree2Path = getWorktreePath(testRepo.path, branch2); + + await apiCreateWorktree(page, testRepo.path, branch1); + await apiCreateWorktree(page, testRepo.path, branch2); + + // Create different files in each worktree + const file1Path = path.join(worktree1Path, "worktree1-only.txt"); + const file2Path = path.join(worktree2Path, "worktree2-only.txt"); + + fs.writeFileSync(file1Path, "File only in worktree 1"); + fs.writeFileSync(file2Path, "File only in worktree 2"); + + // Verify file1 only exists in worktree1 + expect(fs.existsSync(file1Path)).toBe(true); + expect(fs.existsSync(path.join(worktree2Path, "worktree1-only.txt"))).toBe(false); + + // Verify file2 only exists in worktree2 + expect(fs.existsSync(file2Path)).toBe(true); + expect(fs.existsSync(path.join(worktree1Path, "worktree2-only.txt"))).toBe(false); + + // Commit in worktree1 + await execAsync("git add worktree1-only.txt", { cwd: worktree1Path }); + await execAsync('git commit -m "Add file in worktree1"', { cwd: worktree1Path }); + + // Commit in worktree2 + await execAsync("git add worktree2-only.txt", { cwd: worktree2Path }); + await execAsync('git commit -m "Add file in worktree2"', { cwd: worktree2Path }); + + // Verify commits are separate + const { stdout: log1 } = await execAsync("git log --oneline -1", { cwd: worktree1Path }); + const { stdout: log2 } = await execAsync("git log --oneline -1", { cwd: worktree2Path }); + + expect(log1).toContain("Add file in worktree1"); + expect(log2).toContain("Add file in worktree2"); + expect(log1).not.toContain("Add file in worktree2"); + expect(log2).not.toContain("Add file in worktree1"); + }); + + test("should detect modified files count in worktree listing", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/changes-detection"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Create multiple modified files + fs.writeFileSync(path.join(worktreePath, "change1.txt"), "Change 1"); + fs.writeFileSync(path.join(worktreePath, "change2.txt"), "Change 2"); + fs.writeFileSync(path.join(worktreePath, "change3.txt"), "Change 3"); + + // List worktrees and check for changes + const { response, data } = await apiListWorktrees(page, testRepo.path, true); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + + // Find the worktree we created + const changedWorktree = data.worktrees.find((w) => w.branch === branchName); + expect(changedWorktree).toBeDefined(); + expect(changedWorktree?.hasChanges).toBe(true); + expect(changedWorktree?.changedFilesCount).toBeGreaterThanOrEqual(3); + }); + + // ========================================================================== + // Existing Branch Handling + // ========================================================================== + + test("should create worktree from existing branch", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // First, create a branch with some commits (without worktree) + const branchName = "feature/existing-branch"; + await execAsync(`git branch ${branchName}`, { cwd: testRepo.path }); + await execAsync(`git checkout ${branchName}`, { cwd: testRepo.path }); + fs.writeFileSync(path.join(testRepo.path, "existing-file.txt"), "Content from existing branch"); + await execAsync("git add existing-file.txt", { cwd: testRepo.path }); + await execAsync('git commit -m "Commit on existing branch"', { cwd: testRepo.path }); + await execAsync("git checkout main", { cwd: testRepo.path }); + + // Now create a worktree for that existing branch + const expectedWorktreePath = getWorktreePath(testRepo.path, branchName); + + const { response, data } = await apiCreateWorktree(page, testRepo.path, branchName); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + + // Verify the worktree has the file from the existing branch + const existingFilePath = path.join(expectedWorktreePath, "existing-file.txt"); + expect(fs.existsSync(existingFilePath)).toBe(true); + const content = fs.readFileSync(existingFilePath, "utf-8"); + expect(content).toBe("Content from existing branch"); + }); + + test("should return existing worktree when creating with same branch name", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create first worktree + const branchName = "feature/duplicate-test"; + const { response: response1, data: data1 } = await apiCreateWorktree(page, testRepo.path, branchName); + expect(response1.ok()).toBe(true); + expect(data1.success).toBe(true); + expect(data1.worktree?.isNew).not.toBe(false); // New branch was created + + // Try to create another worktree with same branch name + // This should succeed and return the existing worktree (not an error) + const { response: response2, data: data2 } = await apiCreateWorktree(page, testRepo.path, branchName); + + expect(response2.ok()).toBe(true); + expect(data2.success).toBe(true); + expect(data2.worktree?.isNew).toBe(false); // Not a new creation, returned existing + expect(data2.worktree?.branch).toBe(branchName); + }); + + // ========================================================================== + // Feature Integration + // ========================================================================== + test("should add a feature to backlog with specific branch", async ({ page }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); @@ -440,12 +582,7 @@ test.describe("Worktree Integration Tests", () => { // Create a worktree first const branchName = "feature/test-branch"; - await page.request.post("http://localhost:3008/api/worktree/create", { - data: { - projectPath: testRepo.path, - branchName: branchName, - }, - }); + await apiCreateWorktree(page, testRepo.path, branchName); // Click add feature button await clickAddFeature(page); @@ -459,7 +596,7 @@ test.describe("Worktree Integration Tests", () => { // Confirm await confirmAddFeature(page); - // Wait for the feature to appear in the backlog + // Wait for the feature to appear await page.waitForTimeout(1000); // Verify feature was created with correct branch by checking the filesystem @@ -478,7 +615,7 @@ test.describe("Worktree Integration Tests", () => { }); test("should filter features by selected worktree", async ({ page }) => { - // Create the worktrees first + // Create the worktrees first (using git directly for setup) await execAsync(`git worktree add ".worktrees/feature-worktree-a" -b feature/worktree-a`, { cwd: testRepo.path, }); @@ -496,14 +633,13 @@ test.describe("Worktree Integration Tests", () => { await mainButton.click(); await page.waitForTimeout(500); - // Create feature for main branch - don't specify branch, use the default (main) + // Create feature for main branch await clickAddFeature(page); - // Just fill description without specifying branch - it should default to main const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first(); await descriptionInput.fill("Feature for main branch"); await confirmAddFeature(page); - // Wait for feature to be created and visible in backlog + // Wait for feature to be visible const mainFeatureText = page.getByText("Feature for main branch"); await expect(mainFeatureText).toBeVisible({ timeout: 10000 }); @@ -515,7 +651,7 @@ test.describe("Worktree Integration Tests", () => { // Main feature should not be visible now await expect(mainFeatureText).not.toBeVisible(); - // Create feature for worktree-a - don't specify branch, use the default + // Create feature for worktree-a await clickAddFeature(page); const descriptionInput2 = page.locator('[data-testid="add-feature-dialog"] textarea').first(); await descriptionInput2.fill("Feature for worktree A"); @@ -548,14 +684,6 @@ test.describe("Worktree Integration Tests", () => { await expect(mainFeatureText).toBeVisible({ timeout: 10000 }); await expect(worktreeAText).not.toBeVisible(); await expect(worktreeBText).not.toBeVisible(); - - // Switch to worktree-a and verify - await worktreeAButton.click(); - await page.waitForTimeout(500); - - await expect(worktreeAText).toBeVisible({ timeout: 10000 }); - await expect(mainFeatureText).not.toBeVisible(); - await expect(worktreeBText).not.toBeVisible(); }); test("should pre-fill branch when creating feature from selected worktree", async ({ page }) => { @@ -582,7 +710,6 @@ test.describe("Worktree Integration Tests", () => { await clickAddFeature(page); // Verify the branch input button shows the selected worktree's branch - // The branch input is a combobox button, so check its text content const branchButton = page.locator('[data-testid="feature-branch-input"]'); await expect(branchButton).toContainText(branchName, { timeout: 5000 }); @@ -590,42 +717,59 @@ test.describe("Worktree Integration Tests", () => { await page.keyboard.press("Escape"); }); - test("should list worktrees via API", async ({ page }) => { + // ========================================================================== + // Error Handling + // ========================================================================== + + test("should handle commit with missing required fields", async ({ page }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); - // Create some worktrees first - await page.request.post("http://localhost:3008/api/worktree/create", { - data: { - projectPath: testRepo.path, - branchName: "feature/list-test-1", - }, - }); - await page.request.post("http://localhost:3008/api/worktree/create", { - data: { - projectPath: testRepo.path, - branchName: "feature/list-test-2", - }, + // Try to commit without worktreePath + const response1 = await page.request.post("http://localhost:3008/api/worktree/commit", { + data: { message: "Missing worktreePath" }, }); - // List worktrees via API - const listResponse = await page.request.post("http://localhost:3008/api/worktree/list", { - data: { - projectPath: testRepo.path, - includeDetails: true, - }, + expect(response1.ok()).toBe(false); + const result1 = await response1.json(); + expect(result1.success).toBe(false); + expect(result1.error).toContain("worktreePath"); + + // Try to commit without message + const response2 = await page.request.post("http://localhost:3008/api/worktree/commit", { + data: { worktreePath: testRepo.path }, }); - expect(listResponse.ok()).toBe(true); - const result = await listResponse.json(); - expect(result.success).toBe(true); - expect(result.worktrees).toHaveLength(3); // main + 2 worktrees + expect(response2.ok()).toBe(false); + const result2 = await response2.json(); + expect(result2.success).toBe(false); + expect(result2.error).toContain("message"); + }); - // Verify worktree details - const branches = result.worktrees.map((w: { branch: string }) => w.branch); - expect(branches).toContain("main"); - expect(branches).toContain("feature/list-test-1"); - expect(branches).toContain("feature/list-test-2"); + test("should handle switch-branch with missing required fields", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Try to switch without worktreePath + const response1 = await page.request.post("http://localhost:3008/api/worktree/switch-branch", { + data: { branchName: "some-branch" }, + }); + + expect(response1.ok()).toBe(false); + const result1 = await response1.json(); + expect(result1.success).toBe(false); + expect(result1.error).toContain("worktreePath"); + + // Try to switch without branchName + const response2 = await page.request.post("http://localhost:3008/api/worktree/switch-branch", { + data: { worktreePath: testRepo.path }, + }); + + expect(response2.ok()).toBe(false); + const result2 = await response2.json(); + expect(result2.success).toBe(false); + expect(result2.error).toContain("branchName"); }); });