| |
| |
| |
| |
|
|
| 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'; |
| import { getWorkspaceRoot } from '../core/safe-paths'; |
|
|
| const execAsync = promisify(exec); |
|
|
| |
| |
| |
|
|
| export interface TestRepo { |
| path: string; |
| cleanup: () => Promise<void>; |
| } |
|
|
| export interface FeatureData { |
| id: string; |
| category: string; |
| description: string; |
| status: string; |
| branchName?: string; |
| worktreePath?: string; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| 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}`); |
| } |
|
|
| |
| |
| |
| export function getWorktreePath(projectPath: string, branchName: string): string { |
| const sanitizedName = sanitizeBranchName(branchName); |
| return path.join(projectPath, '.worktrees', sanitizedName); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export async function createTestGitRepo(tempDir: string): Promise<TestRepo> { |
| |
| if (!fs.existsSync(tempDir)) { |
| fs.mkdirSync(tempDir, { recursive: true }); |
| } |
|
|
| const tmpDir = path.join(tempDir, `test-repo-${Date.now()}`); |
| fs.mkdirSync(tmpDir, { recursive: true }); |
|
|
| |
| |
| const gitEnv = { |
| ...process.env, |
| GIT_AUTHOR_NAME: 'Test User', |
| GIT_AUTHOR_EMAIL: 'test@example.com', |
| GIT_COMMITTER_NAME: 'Test User', |
| GIT_COMMITTER_EMAIL: 'test@example.com', |
| }; |
|
|
| |
| |
| try { |
| await execAsync('git init -b main', { cwd: tmpDir, env: gitEnv }); |
| } catch { |
| |
| await execAsync('git init', { cwd: tmpDir, env: gitEnv }); |
| } |
|
|
| |
| fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n'); |
| await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); |
| await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); |
|
|
| |
| await execAsync('git branch -M main', { cwd: tmpDir, env: gitEnv }); |
|
|
| |
| const automakerDir = path.join(tmpDir, '.automaker'); |
| const featuresDir = path.join(automakerDir, 'features'); |
| fs.mkdirSync(featuresDir, { recursive: true }); |
|
|
| |
| fs.writeFileSync(path.join(automakerDir, 'categories.json'), '[]'); |
|
|
| return { |
| path: tmpDir, |
| cleanup: async () => { |
| await cleanupTestRepo(tmpDir); |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| export async function cleanupTestRepo(repoPath: string): Promise<void> { |
| try { |
| |
| const { stdout } = await execAsync('git worktree list --porcelain', { |
| cwd: repoPath, |
| }).catch(() => ({ stdout: '' })); |
|
|
| const worktrees = stdout |
| .split('\n\n') |
| .slice(1) |
| .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 { |
| |
| } |
| } |
|
|
| |
| fs.rmSync(repoPath, { recursive: true, force: true }); |
| } catch (error) { |
| console.error('Failed to cleanup test repo:', error); |
| } |
| } |
|
|
| |
| |
| |
| function rmDirRecursive(dir: string): void { |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); |
| for (const entry of entries) { |
| const fullPath = path.join(dir, entry.name); |
| if (entry.isDirectory()) { |
| rmDirRecursive(fullPath); |
| fs.rmdirSync(fullPath); |
| } else { |
| fs.unlinkSync(fullPath); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| export function cleanupTempDir(tempDir: string): void { |
| if (!fs.existsSync(tempDir)) return; |
| try { |
| fs.rmSync(tempDir, { recursive: true, force: true }); |
| } catch (err) { |
| const code = (err as NodeJS.ErrnoException)?.code; |
| if (code === 'ENOENT') { |
| |
| } else if (code === 'ENOTEMPTY' || code === 'EPERM' || code === 'EBUSY') { |
| rmDirRecursive(tempDir); |
| try { |
| fs.rmdirSync(tempDir); |
| } catch (e2) { |
| if ((e2 as NodeJS.ErrnoException)?.code !== 'ENOENT') { |
| throw e2; |
| } |
| } |
| } else { |
| throw err; |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export async function gitExec( |
| repoPath: string, |
| command: string |
| ): Promise<{ stdout: string; stderr: string }> { |
| return execAsync(`git ${command}`, { cwd: repoPath }); |
| } |
|
|
| |
| |
| |
| 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) |
| .map((block) => { |
| const pathLine = block.split('\n').find((line) => line.startsWith('worktree ')); |
| if (!pathLine) return null; |
| |
| const worktreePath = pathLine.replace('worktree ', ''); |
| return path.normalize(worktreePath); |
| }) |
| .filter(Boolean) as string[]; |
| } catch { |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| export async function getCurrentBranch(repoPath: string): Promise<string> { |
| const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: repoPath }); |
| return stdout.trim(); |
| } |
|
|
| |
| |
| |
| export async function createBranch(repoPath: string, branchName: string): Promise<void> { |
| await execAsync(`git branch ${branchName}`, { cwd: repoPath }); |
| } |
|
|
| |
| |
| |
| export async function checkoutBranch(repoPath: string, branchName: string): Promise<void> { |
| await execAsync(`git checkout ${branchName}`, { cwd: repoPath }); |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| export async function commitFile( |
| repoPath: string, |
| filePath: string, |
| content: string, |
| message: string |
| ): Promise<void> { |
| |
| const gitEnv = { |
| ...process.env, |
| GIT_AUTHOR_NAME: 'Test User', |
| GIT_AUTHOR_EMAIL: 'test@example.com', |
| GIT_COMMITTER_NAME: 'Test User', |
| GIT_COMMITTER_EMAIL: 'test@example.com', |
| }; |
|
|
| fs.writeFileSync(path.join(repoPath, filePath), content); |
| await execAsync(`git add "${filePath}"`, { cwd: repoPath, env: gitEnv }); |
| await execAsync(`git commit -m "${message}"`, { cwd: repoPath, env: gitEnv }); |
| } |
|
|
| |
| |
| |
| export async function getLatestCommitMessage(repoPath: string): Promise<string> { |
| const { stdout } = await execAsync('git log --oneline -1', { cwd: repoPath }); |
| return stdout.trim(); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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)); |
| } |
|
|
| |
| |
| |
| 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')); |
| } |
|
|
| |
| |
| |
| export function listTestFeatures(repoPath: string): string[] { |
| const featuresDir = path.join(repoPath, '.automaker', 'features'); |
|
|
| if (!fs.existsSync(featuresDir)) { |
| return []; |
| } |
|
|
| return fs.readdirSync(featuresDir); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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, |
| skipSandboxWarning: true, |
| apiKeys: { anthropic: '', google: '' }, |
| chatSessions: [], |
| chatHistoryOpen: false, |
| maxConcurrency: 3, |
| useWorktrees: true, |
| currentWorktreeByProject: { |
| [pathArg]: { path: null, branch: 'main' }, |
| }, |
| worktreesByProject: {}, |
| }, |
| version: 2, |
| }; |
|
|
| localStorage.setItem('automaker-storage', JSON.stringify(mockState)); |
|
|
| |
| const setupState = { |
| state: { |
| isFirstRun: false, |
| setupComplete: true, |
| currentStep: 'complete', |
| skipClaudeSetup: false, |
| }, |
| version: 0, |
| }; |
| localStorage.setItem('automaker-setup', JSON.stringify(setupState)); |
|
|
| |
| localStorage.setItem('automaker-disable-splash', 'true'); |
| }, projectPath); |
| } |
|
|
| |
| |
| |
| |
| 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, |
| skipSandboxWarning: true, |
| apiKeys: { anthropic: '', google: '' }, |
| chatSessions: [], |
| chatHistoryOpen: false, |
| maxConcurrency: 3, |
| useWorktrees: false, |
| currentWorktreeByProject: {}, |
| worktreesByProject: {}, |
| }, |
| version: 2, |
| }; |
|
|
| localStorage.setItem('automaker-storage', JSON.stringify(mockState)); |
|
|
| |
| const setupState = { |
| state: { |
| isFirstRun: false, |
| setupComplete: true, |
| currentStep: 'complete', |
| skipClaudeSetup: false, |
| }, |
| version: 0, |
| }; |
| localStorage.setItem('automaker-setup', JSON.stringify(setupState)); |
|
|
| |
| localStorage.setItem('automaker-disable-splash', 'true'); |
| }, projectPath); |
| } |
|
|
| |
| |
| |
| |
| |
| 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, |
| skipSandboxWarning: true, |
| apiKeys: { anthropic: '', google: '' }, |
| chatSessions: [], |
| chatHistoryOpen: false, |
| maxConcurrency: 3, |
| useWorktrees: true, |
| currentWorktreeByProject: { |
| |
| [pathArg]: { path: '/non/existent/worktree/path', branch: 'feature/deleted-branch' }, |
| }, |
| worktreesByProject: {}, |
| }, |
| version: 2, |
| }; |
|
|
| localStorage.setItem('automaker-storage', JSON.stringify(mockState)); |
|
|
| |
| const setupState = { |
| state: { |
| isFirstRun: false, |
| setupComplete: true, |
| currentStep: 'complete', |
| skipClaudeSetup: false, |
| }, |
| version: 0, |
| }; |
| localStorage.setItem('automaker-setup', JSON.stringify(setupState)); |
|
|
| |
| localStorage.setItem('automaker-disable-splash', 'true'); |
| }, projectPath); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export async function waitForBoardView(page: Page): Promise<void> { |
| |
| const currentUrl = page.url(); |
| if (!currentUrl.includes('/board')) { |
| await page.goto('/board'); |
| await page.waitForLoadState('load'); |
| } |
|
|
| |
| |
| await page.waitForFunction( |
| () => { |
| const boardView = document.querySelector('[data-testid="board-view"]'); |
| |
| return boardView !== null; |
| }, |
| { timeout: TIMEOUTS.long } |
| ); |
| } |
|
|
| |
| |
| |
| export async function waitForWorktreeSelector(page: Page): Promise<void> { |
| await page |
| .waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }) |
| .catch(() => { |
| |
| return page.getByText('Branch:').waitFor({ timeout: TIMEOUTS.medium }); |
| }); |
| } |
|
|