mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- Updated test repository creation functions to utilize environment variables for git author and committer information, preventing modifications to the user's global git configuration. - This change enhances test isolation and ensures consistent behavior across different environments.
525 lines
16 KiB
TypeScript
525 lines
16 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 });
|
|
|
|
// Use environment variables instead of git config to avoid affecting user's git config
|
|
// These env vars override git config without modifying it
|
|
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',
|
|
};
|
|
|
|
// Create initial commit
|
|
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 });
|
|
|
|
// 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> {
|
|
// Use environment variables instead of git config to avoid affecting user's git config
|
|
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 });
|
|
}
|
|
|
|
/**
|
|
* 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: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0
|
|
};
|
|
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: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0
|
|
};
|
|
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: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0
|
|
};
|
|
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('load');
|
|
}
|
|
|
|
// 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 });
|
|
});
|
|
}
|