Files
automaker/apps/ui/tests/utils/git/worktree.ts
Kacper 26236d3d5b feat: enhance ESLint configuration and improve component error handling
- Updated ESLint configuration to include support for `.mjs` and `.cjs` file types, adding necessary global variables for Node.js and browser environments.
- Introduced a new `vite-env.d.ts` file to define environment variables for Vite, improving type safety.
- Refactored error handling in `file-browser-dialog.tsx`, `description-image-dropzone.tsx`, and `feature-image-upload.tsx` to omit error parameters, simplifying the catch blocks.
- Removed unused bug report button functionality from the sidebar, streamlining the component structure.
- Adjusted various components to improve code readability and maintainability, including updates to type imports and component props.

These changes aim to enhance the development experience by improving linting support and simplifying error handling across components.
2025-12-21 23:08:08 +01:00

508 lines
15 KiB
TypeScript

/**
* 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/ui')) {
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 });
// Create empty categories.json to avoid ENOENT errors in tests
fs.writeFileSync(path.join(automakerDir, 'categories.json'), '[]');
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 '));
if (!pathLine) return null;
// Normalize path separators to OS native (git on Windows returns forward slashes)
const worktreePath = pathLine.replace('worktree ', '');
return path.normalize(worktreePath);
})
.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: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
[pathArg]: { path: null, branch: 'main' }, // Initialize to main branch
},
worktreesByProject: {},
},
version: 2, // Must match app-store.ts persist version
};
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: 2, // Must match app-store.ts persist version
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
/**
* Set up localStorage with a project pointing to a test repo with worktrees DISABLED
* Use this to test scenarios where the worktree feature flag is off
*/
export async function setupProjectWithPathNoWorktrees(
page: Page,
projectPath: string
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: 'test-project-no-worktree',
name: 'Test Project (No Worktrees)',
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: [],
useWorktrees: false, // Worktree feature DISABLED
currentWorktreeByProject: {},
worktreesByProject: {},
},
version: 2, // Must match app-store.ts persist version
};
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: 2, // Must match app-store.ts persist version
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
/**
* Set up localStorage with a project that has STALE worktree data
* The currentWorktreeByProject points to a worktree path that no longer exists
* This simulates the scenario where a user previously selected a worktree that was later deleted
*/
export async function setupProjectWithStaleWorktree(
page: Page,
projectPath: string
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: 'test-project-stale-worktree',
name: 'Stale 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: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
// This is STALE data - pointing to a worktree path that doesn't exist
[pathArg]: { path: '/non/existent/worktree/path', branch: 'feature/deleted-branch' },
},
worktreesByProject: {},
},
version: 2, // Must match app-store.ts persist version
};
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: 2, // Must match app-store.ts persist version
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
// ============================================================================
// Wait Utilities
// ============================================================================
/**
* Wait for the board view to load
* Navigates to /board first since the index route shows WelcomeView
* Handles zustand store hydration timing (may show "no-project" briefly)
*/
export async function waitForBoardView(page: Page): Promise<void> {
// Navigate directly to /board route (index route shows welcome view)
const currentUrl = page.url();
if (!currentUrl.includes('/board')) {
await page.goto('/board');
await page.waitForLoadState('networkidle');
}
// Wait for either board-view (success) or board-view-no-project (store not hydrated yet)
// Then poll until board-view appears (zustand hydrates from localStorage)
await page.waitForFunction(
() => {
const boardView = document.querySelector('[data-testid="board-view"]');
// Return true only when board-view is visible (store hydrated with project)
return boardView !== null;
},
{ 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 });
});
}