mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
The zustand store may not have hydrated from localStorage by the time the board view first renders, causing board-view-no-project to appear briefly. Use waitForFunction to poll until board-view appears. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
495 lines
15 KiB
TypeScript
495 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/app")) {
|
|
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 "));
|
|
return pathLine ? pathLine.replace("worktree ", "") : null;
|
|
})
|
|
.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: 0,
|
|
};
|
|
|
|
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,
|
|
};
|
|
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: 0,
|
|
};
|
|
|
|
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,
|
|
};
|
|
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: 0,
|
|
};
|
|
|
|
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,
|
|
};
|
|
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"]');
|
|
const noProject = document.querySelector('[data-testid="board-view-no-project"]');
|
|
const loading = document.querySelector('[data-testid="board-view-loading"]');
|
|
// 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 });
|
|
});
|
|
}
|