feat: enhance worktree functionality and UI integration

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,13 @@
export * from "./core/elements";
export * from "./core/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";

View File

@@ -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);
}