/** * 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 shared utilities import { waitForNetworkIdle, apiCreateWorktree, apiDeleteWorktree, apiListWorktrees, apiCommitWorktree, apiSwitchBranch, apiListBranches, createTestGitRepo, cleanupTempDir, createTempDirPath, getWorktreePath, listWorktrees, listBranches, setupProjectWithPath, setupProjectWithPathNoWorktrees, setupProjectWithStaleWorktree, waitForBoardView, clickAddFeature, fillAddFeatureDialog, confirmAddFeature, } from "./utils"; const execAsync = promisify(exec); // ============================================================================ // Test Setup // ============================================================================ // Create unique temp dir for this test run const TEST_TEMP_DIR = createTempDirPath("worktree-tests"); interface TestRepo { path: string; cleanup: () => Promise; } // Configure all tests to run serially to prevent interference test.describe.configure({ mode: "serial" }); // ============================================================================ // Test Suite: Worktree Integration Tests // ============================================================================ test.describe("Worktree Integration Tests", () => { let testRepo: TestRepo; test.beforeAll(async () => { // Create test temp directory if (!fs.existsSync(TEST_TEMP_DIR)) { fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); } }); test.beforeEach(async () => { // Create a fresh test repo for each test testRepo = await createTestGitRepo(TEST_TEMP_DIR); }); test.afterEach(async () => { // Cleanup test repo after each test if (testRepo) { await testRepo.cleanup(); } }); test.afterAll(async () => { // Cleanup temp directory 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 const branchLabel = page.getByText("Branch:"); await expect(branchLabel).toBeVisible({ timeout: 10000 }); // Wait for worktrees to load and main branch button to appear // Use data-testid for more reliable selection const mainBranchButton = page.locator('[data-testid="worktree-branch-main"]'); await expect(mainBranchButton).toBeVisible({ timeout: 15000 }); }); test("should select main branch by default when app loads with stale worktree data", async ({ page, }) => { // Set up project with STALE worktree data in localStorage // This simulates a user who previously selected a worktree that was later deleted await setupProjectWithStaleWorktree(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); // Wait for the worktree selector to load const branchLabel = page.getByText("Branch:"); await expect(branchLabel).toBeVisible({ timeout: 10000 }); // Verify main branch button is displayed const mainBranchButton = page.getByRole("button", { name: "main" }).first(); await expect(mainBranchButton).toBeVisible({ timeout: 10000 }); // CRITICAL: Verify the main branch button is SELECTED (has primary variant styling) // The button should have the "bg-primary" class indicating it's selected // When the bug exists, this will fail because stale data prevents initialization await expect(mainBranchButton).toHaveClass(/bg-primary/, { timeout: 5000 }); }); test("should create a worktree via API and verify filesystem", async ({ page, }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); const branchName = "feature/test-worktree"; 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 worktree was created on filesystem expect(fs.existsSync(expectedWorktreePath)).toBe(true); // Verify branch was created const branches = await listBranches(testRepo.path); expect(branches).toContain(branchName); // Verify worktree is listed by git const worktrees = await listWorktrees(testRepo.path); expect(worktrees.length).toBe(1); expect(worktrees[0]).toBe(expectedWorktreePath); }); test("should create two worktrees and list them both", async ({ page }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); // Create first worktree const { response: response1 } = await apiCreateWorktree( page, testRepo.path, "feature/worktree-one" ); expect(response1.ok()).toBe(true); // Create second worktree const { response: response2 } = await apiCreateWorktree( page, testRepo.path, "feature/worktree-two" ); expect(response2.ok()).toBe(true); // Verify both worktrees exist const worktrees = await listWorktrees(testRepo.path); expect(worktrees.length).toBe(2); // Verify branches were created const branches = await listBranches(testRepo.path); expect(branches).toContain("feature/worktree-one"); expect(branches).toContain("feature/worktree-two"); }); test("should delete a worktree via API and verify cleanup", async ({ page, }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); // Create a worktree const branchName = "feature/to-delete"; const worktreePath = getWorktreePath(testRepo.path, branchName); await apiCreateWorktree(page, testRepo.path, branchName); expect(fs.existsSync(worktreePath)).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); // Verify branch is deleted const branches = await listBranches(testRepo.path); 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.skip("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("/"); await waitForNetworkIdle(page); await waitForBoardView(page); // Note: Worktrees are created at execution time (when feature starts), // not when adding to backlog. We can specify a branch name without // creating a worktree first. const branchName = "feature/test-branch"; // Click add feature button await clickAddFeature(page); // Fill in the feature details with a branch name await fillAddFeatureDialog(page, "Test feature for worktree", { branch: branchName, category: "Testing", }); // Confirm await confirmAddFeature(page); // Wait for the feature to appear await page.waitForTimeout(1000); // Verify feature was created with correct branch by checking the filesystem const featuresDir = path.join(testRepo.path, ".automaker", "features"); const featureDirs = fs.readdirSync(featuresDir); expect(featureDirs.length).toBeGreaterThan(0); // Find and read the feature file const featureDir = featureDirs[0]; const featureFilePath = path.join(featuresDir, featureDir, "feature.json"); const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); expect(featureData.description).toBe("Test feature for worktree"); expect(featureData.branchName).toBe(branchName); expect(featureData.status).toBe("backlog"); // Verify worktreePath is not set when adding to backlog // (worktrees are created at execution time, not when adding to backlog) expect(featureData.worktreePath).toBeUndefined(); }); test("should store branch name when adding feature with new branch (worktree created when adding feature)", async ({ page, }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); // Use a branch name that doesn't exist yet // Note: Worktrees are now created when features are added/edited, not at execution time const branchName = "feature/auto-create-worktree"; // Verify branch does NOT exist before we create the feature const branchesBefore = await listBranches(testRepo.path); expect(branchesBefore).not.toContain(branchName); // Click add feature button await clickAddFeature(page); // Fill in the feature details with the new branch await fillAddFeatureDialog( page, "Feature that should auto-create worktree", { branch: branchName, category: "Testing", } ); // Confirm await confirmAddFeature(page); // Wait for feature to be saved and worktree to be created await page.waitForTimeout(2000); // Verify branch WAS created when adding feature (worktrees are created when features are added/edited) const branchesAfter = await listBranches(testRepo.path); expect(branchesAfter).toContain(branchName); // Verify worktree was created const worktreePath = getWorktreePath(testRepo.path, branchName); expect(fs.existsSync(worktreePath)).toBe(true); // Verify feature was created with correct branch name stored const featuresDir = path.join(testRepo.path, ".automaker", "features"); const featureDirs = fs.readdirSync(featuresDir); expect(featureDirs.length).toBeGreaterThan(0); const featureDir = featureDirs.find((dir) => { const featureFilePath = path.join(featuresDir, dir, "feature.json"); if (fs.existsSync(featureFilePath)) { const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); return data.description === "Feature that should auto-create worktree"; } return false; }); expect(featureDir).toBeDefined(); const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); // Verify branch name is stored expect(featureData.branchName).toBe(branchName); // Verify worktreePath is NOT set (worktrees are created at execution time) expect(featureData.worktreePath).toBeUndefined(); // Verify feature is in backlog status expect(featureData.status).toBe("backlog"); }); test("should auto-select worktree after creating feature with new branch", async ({ page, }) => { await setupProjectWithPath(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); // Use a branch name that doesn't exist yet const branchName = "feature/auto-select-worktree"; // Verify branch does NOT exist before we create the feature const branchesBefore = await listBranches(testRepo.path); expect(branchesBefore).not.toContain(branchName); // Click add feature button await clickAddFeature(page); // Fill in the feature details with the new branch await fillAddFeatureDialog(page, "Feature with auto-select worktree", { branch: branchName, category: "Testing", }); // Confirm await confirmAddFeature(page); // Wait for feature to be saved and worktree to be created // Also wait for the worktree to appear in the UI and be auto-selected await page.waitForTimeout(2000); // Wait for the worktree button to appear in the UI // Worktree buttons are actual