/** * 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