mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: enhance worktree functionality and UI integration
- Updated KanbanCard to conditionally display status badges based on feature attributes, improving visual feedback. - Enhanced WorktreeSelector to conditionally render based on the worktree feature toggle, ensuring a cleaner UI when worktrees are disabled. - Modified AddFeatureDialog and EditFeatureDialog to include branch selection only when worktrees are enabled, streamlining the feature creation process. - Refactored useBoardActions and useBoardDragDrop hooks to create worktrees only when the feature is enabled, optimizing performance. - Introduced comprehensive integration tests for worktree operations, ensuring robust functionality and error handling across various scenarios.
This commit is contained in:
366
apps/app/tests/utils/git/worktree.ts
Normal file
366
apps/app/tests/utils/git/worktree.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 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 });
|
||||
|
||||
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: [],
|
||||
},
|
||||
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
|
||||
*/
|
||||
export async function waitForBoardView(page: Page): Promise<void> {
|
||||
await page.waitForSelector('[data-testid="board-view"]', { 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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user