Merge branch 'AutoMaker-Org:main' into claude/task-dependency-graph-iPz1k

This commit is contained in:
James Botwina
2025-12-22 14:23:18 -05:00
committed by GitHub
44 changed files with 2928 additions and 446 deletions

30
.github/workflows/security-audit.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Security Audit
on:
pull_request:
branches:
- '*'
push:
branches:
- main
- master
schedule:
# Run weekly on Mondays at 9 AM UTC
- cron: '0 9 * * 1'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
- name: Run npm audit
run: npm audit --audit-level=moderate
continue-on-error: false

View File

@@ -118,7 +118,10 @@ cd automaker
# 2. Install dependencies
npm install
# 3. Run Automaker (pick your mode)
# 3. Build local shared packages
npm run build:packages
# 4. Run Automaker (pick your mode)
npm run dev
# Then choose your run mode when prompted, or use specific commands below
```

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/dev/types/routes.d.ts';
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -46,6 +46,7 @@ import { SettingsService } from './services/settings-service.js';
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
import { createClaudeRoutes } from './routes/claude/index.js';
import { ClaudeUsageService } from './services/claude-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
// Load environment variables
@@ -146,6 +147,7 @@ app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes());
app.use('/api/context', createContextRoutes());
// Create HTTP server

View File

@@ -0,0 +1,18 @@
/**
* GitHub routes - HTTP API for GitHub integration
*/
import { Router } from 'express';
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js';
export function createGitHubRoutes(): Router {
const router = Router();
router.post('/check-remote', createCheckGitHubRemoteHandler());
router.post('/issues', createListIssuesHandler());
router.post('/prs', createListPRsHandler());
return router;
}

View File

@@ -0,0 +1,71 @@
/**
* GET /check-github-remote endpoint - Check if project has a GitHub remote
*/
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
export interface GitHubRemoteStatus {
hasGitHubRemote: boolean;
remoteUrl: string | null;
owner: string | null;
repo: string | null;
}
export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemoteStatus> {
const status: GitHubRemoteStatus = {
hasGitHubRemote: false,
remoteUrl: null,
owner: null,
repo: null,
};
try {
// Get the remote URL (origin by default)
const { stdout } = await execAsync('git remote get-url origin', {
cwd: projectPath,
env: execEnv,
});
const remoteUrl = stdout.trim();
status.remoteUrl = remoteUrl;
// Check if it's a GitHub URL
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/);
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/);
const match = httpsMatch || sshMatch;
if (match) {
status.hasGitHubRemote = true;
status.owner = match[1];
status.repo = match[2].replace(/\.git$/, '');
}
} catch {
// No remote or not a git repo - that's okay
}
return status;
}
export function createCheckGitHubRemoteHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const status = await checkGitHubRemote(projectPath);
res.json({
success: true,
...status,
});
} catch (error) {
logError(error, 'Check GitHub remote failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,35 @@
/**
* Common utilities for GitHub routes
*/
import { exec } from 'child_process';
import { promisify } from 'util';
export const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
export const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
export const execEnv = {
...process.env,
PATH: extendedPath,
};
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
console.error(`[GitHub] ${context}:`, error);
}

View File

@@ -0,0 +1,90 @@
/**
* POST /list-issues endpoint - List GitHub issues for a project
*/
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
export interface GitHubLabel {
name: string;
color: string;
}
export interface GitHubAuthor {
login: string;
}
export interface GitHubIssue {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
body: string;
}
export interface ListIssuesResult {
success: boolean;
openIssues?: GitHubIssue[];
closedIssues?: GitHubIssue[];
error?: string;
}
export function createListIssuesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// First check if this is a GitHub repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
// Fetch open and closed issues in parallel
const [openResult, closedResult] = await Promise.all([
execAsync(
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
{
cwd: projectPath,
env: execEnv,
}
),
execAsync(
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
{
cwd: projectPath,
env: execEnv,
}
),
]);
const { stdout: openStdout } = openResult;
const { stdout: closedStdout } = closedResult;
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
res.json({
success: true,
openIssues,
closedIssues,
});
} catch (error) {
logError(error, 'List GitHub issues failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,92 @@
/**
* POST /list-prs endpoint - List GitHub pull requests for a project
*/
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
export interface GitHubLabel {
name: string;
color: string;
}
export interface GitHubAuthor {
login: string;
}
export interface GitHubPR {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
isDraft: boolean;
headRefName: string;
reviewDecision: string | null;
mergeable: string;
body: string;
}
export interface ListPRsResult {
success: boolean;
openPRs?: GitHubPR[];
mergedPRs?: GitHubPR[];
error?: string;
}
export function createListPRsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// First check if this is a GitHub repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const [openResult, mergedResult] = await Promise.all([
execAsync(
'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100',
{
cwd: projectPath,
env: execEnv,
}
),
execAsync(
'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50',
{
cwd: projectPath,
env: execEnv,
}
),
]);
const { stdout: openStdout } = openResult;
const { stdout: mergedStdout } = mergedResult;
const openPRs: GitHubPR[] = JSON.parse(openStdout || '[]');
const mergedPRs: GitHubPR[] = JSON.parse(mergedStdout || '[]');
res.json({
success: true,
openPRs,
mergedPRs,
});
} catch (error) {
logError(error, 'List GitHub PRs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -23,10 +23,6 @@ const suggestionsSchema = {
id: { type: 'string' },
category: { type: 'string' },
description: { type: 'string' },
steps: {
type: 'array',
items: { type: 'string' },
},
priority: {
type: 'number',
minimum: 1,
@@ -34,7 +30,7 @@ const suggestionsSchema = {
},
reasoning: { type: 'string' },
},
required: ['category', 'description', 'steps', 'priority', 'reasoning'],
required: ['category', 'description', 'priority', 'reasoning'],
},
},
},
@@ -62,9 +58,8 @@ Look at the codebase and provide 3-5 concrete suggestions.
For each suggestion, provide:
1. A category (e.g., "User Experience", "Security", "Performance")
2. A clear description of what to implement
3. Concrete steps to implement it
4. Priority (1=high, 2=medium, 3=low)
5. Brief reasoning for why this would help
3. Priority (1=high, 2=medium, 3=low)
4. Brief reasoning for why this would help
The response will be automatically formatted as structured JSON.`;
@@ -164,7 +159,6 @@ The response will be automatically formatted as structured JSON.`;
id: `suggestion-${Date.now()}-0`,
category: 'Analysis',
description: 'Review the AI analysis output for insights',
steps: ['Review the generated analysis'],
priority: 1,
reasoning: 'The AI provided analysis but suggestions need manual review',
},

View File

@@ -607,15 +607,18 @@ export class AutoModeService {
}
);
// Mark as waiting_approval for user review
await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval');
// Determine final status based on testing mode:
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: true,
message: `Feature completed in ${Math.round(
(Date.now() - tempRunningFeature.startTime) / 1000
)}s`,
)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
projectPath,
});
} catch (error) {
@@ -879,13 +882,16 @@ Address the follow-up instructions above. Review the previous work and make the
}
);
// Mark as waiting_approval for user review
await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval');
// Determine final status based on testing mode:
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: true,
message: 'Follow-up completed successfully',
message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
projectPath,
});
} catch (error) {
@@ -1606,15 +1612,17 @@ You can use the Read tool to view these images at any time during implementation
`;
}
prompt += `
// Add verification instructions based on testing mode
if (feature.skipTests) {
// Manual verification - just implement the feature
prompt += `
## Instructions
Implement this feature by:
1. First, explore the codebase to understand the existing structure
2. Plan your implementation approach
3. Write the necessary code changes
4. Add or update tests as needed
5. Ensure the code follows existing patterns and conventions
4. Ensure the code follows existing patterns and conventions
When done, wrap your final summary in <summary> tags like this:
@@ -1632,6 +1640,56 @@ When done, wrap your final summary in <summary> tags like this:
</summary>
This helps parse your summary correctly in the output logs.`;
} else {
// Automated testing - implement and verify with Playwright
prompt += `
## Instructions
Implement this feature by:
1. First, explore the codebase to understand the existing structure
2. Plan your implementation approach
3. Write the necessary code changes
4. Ensure the code follows existing patterns and conventions
## Verification with Playwright (REQUIRED)
After implementing the feature, you MUST verify it works correctly using Playwright:
1. **Create a temporary Playwright test** to verify the feature works as expected
2. **Run the test** to confirm the feature is working
3. **Delete the test file** after verification - this is a temporary verification test, not a permanent test suite addition
Example verification workflow:
\`\`\`bash
# Create a simple verification test
npx playwright test my-verification-test.spec.ts
# After successful verification, delete the test
rm my-verification-test.spec.ts
\`\`\`
The test should verify the core functionality of the feature. If the test fails, fix the implementation and re-test.
When done, wrap your final summary in <summary> tags like this:
<summary>
## Summary: [Feature Title]
### Changes Implemented
- [List of changes made]
### Files Modified
- [List of files]
### Verification Status
- [Describe how the feature was verified with Playwright]
### Notes for Developer
- [Any important notes]
</summary>
This helps parse your summary correctly in the output logs.`;
}
return prompt;
}

View File

@@ -146,6 +146,7 @@ describe('auto-mode-service.ts (integration)', () => {
category: 'test',
description: 'Test without worktree',
status: 'pending',
skipTests: true,
});
const mockProvider = {
@@ -181,6 +182,7 @@ describe('auto-mode-service.ts (integration)', () => {
category: 'ui',
description: 'Execute this feature',
status: 'pending',
skipTests: true,
});
const mockProvider = {
@@ -327,6 +329,7 @@ describe('auto-mode-service.ts (integration)', () => {
category: 'test',
description: 'Auto feature 1',
status: 'pending',
skipTests: true,
});
await createTestFeature(testRepo.path, 'auto-2', {
@@ -334,6 +337,7 @@ describe('auto-mode-service.ts (integration)', () => {
category: 'test',
description: 'Auto feature 2',
status: 'pending',
skipTests: true,
});
const mockProvider = {
@@ -520,6 +524,7 @@ describe('auto-mode-service.ts (integration)', () => {
description: 'Feature with skip planning',
status: 'pending',
planningMode: 'skip',
skipTests: true,
});
const mockProvider = {
@@ -555,6 +560,7 @@ describe('auto-mode-service.ts (integration)', () => {
status: 'pending',
planningMode: 'lite',
requirePlanApproval: false,
skipTests: true,
});
const mockProvider = {

View File

@@ -1,5 +1,5 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { cn, isMac } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
@@ -20,7 +20,9 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center'
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center',
// Add left padding on macOS to avoid overlapping with traffic light buttons
isMac && 'pt-4 pl-20'
)}
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />

View File

@@ -51,7 +51,9 @@ export function SidebarNavigation({
return (
<button
key={item.id}
onClick={() => navigate({ to: `/${item.id}` as const })}
onClick={() => {
navigate({ to: `/${item.id}` as const });
}}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',

View File

@@ -1,9 +1,20 @@
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react';
import {
FileText,
LayoutGrid,
Bot,
BookOpen,
UserCircle,
Terminal,
CircleDot,
GitPullRequest,
Zap,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
interface UseNavigationProps {
shortcuts: {
@@ -51,6 +62,30 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
useEffect(() => {
async function checkGitHubRemote() {
if (!currentProject?.path) {
setHasGitHubRemote(false);
return;
}
try {
const api = getElectronAPI();
if (api.github) {
const result = await api.github.checkRemote(currentProject.path);
setHasGitHubRemote(result.success && result.hasGitHubRemote === true);
}
} catch {
setHasGitHubRemote(false);
}
}
checkGitHubRemote();
}, [currentProject?.path]);
// Build navigation sections
const navSections: NavSection[] = useMemo(() => {
const allToolsItems: NavItem[] = [
@@ -114,7 +149,7 @@ export function useNavigation({
});
}
return [
const sections: NavSection[] = [
{
label: 'Project',
items: projectItems,
@@ -124,7 +159,28 @@ export function useNavigation({
items: visibleToolsItems,
},
];
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
// Add GitHub section if project has a GitHub remote
if (hasGitHubRemote) {
sections.push({
label: 'GitHub',
items: [
{
id: 'github-issues',
label: 'Issues',
icon: CircleDot,
},
{
id: 'github-prs',
label: 'Pull Requests',
icon: GitPullRequest,
},
],
});
}
return sections;
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {

View File

@@ -40,7 +40,7 @@ export function useProjectCreation({
// Write initial app_spec.txt with proper XML structure
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
const api = getElectronAPI();
await api.fs.writeFile(
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
@@ -103,7 +103,7 @@ export function useProjectCreation({
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
await api.fs.createFolder(projectPath);
await api.mkdir(projectPath);
// Finalize project setup
await finalizeProjectCreation(projectPath, projectName);
@@ -127,16 +127,19 @@ export function useProjectCreation({
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone template repository
await api.git.clone(template.githubUrl, projectPath);
const cloneResult = await api.templates.clone(template.repoUrl, projectName, parentDir);
if (!cloneResult.success) {
throw new Error(cloneResult.error || 'Failed to clone template');
}
const projectPath = cloneResult.projectPath!;
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write app_spec.txt with template-specific info
await api.fs.writeFile(
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
@@ -196,16 +199,19 @@ export function useProjectCreation({
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone custom repository
await api.git.clone(repoUrl, projectPath);
const cloneResult = await api.templates.clone(repoUrl, projectName, parentDir);
if (!cloneResult.success) {
throw new Error(cloneResult.error || 'Failed to clone repository');
}
const projectPath = cloneResult.projectPath!;
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write app_spec.txt with custom URL info
await api.fs.writeFile(
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>

View File

@@ -426,7 +426,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
interface DetectedFeature {
category: string;
description: string;
steps: string[];
passes: boolean;
}
@@ -453,11 +452,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Testing',
description: 'Automated test suite',
steps: [
'Step 1: Tests directory exists',
'Step 2: Test files are present',
'Step 3: Run test suite',
],
passes: true,
});
}
@@ -471,11 +465,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'UI/Design',
description: 'Component-based UI architecture',
steps: [
'Step 1: Components directory exists',
'Step 2: UI components are defined',
'Step 3: Components are reusable',
],
passes: true,
});
}
@@ -485,11 +474,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Project Structure',
description: 'Organized source code structure',
steps: [
'Step 1: Source directory exists',
'Step 2: Code is properly organized',
'Step 3: Follows best practices',
],
passes: true,
});
}
@@ -504,11 +488,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Frontend',
description: 'React-based user interface',
steps: [
'Step 1: React is installed',
'Step 2: Components render correctly',
'Step 3: State management works',
],
passes: true,
});
}
@@ -517,11 +496,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Framework',
description: 'Next.js framework integration',
steps: [
'Step 1: Next.js is configured',
'Step 2: Pages/routes are defined',
'Step 3: Server-side rendering works',
],
passes: true,
});
}
@@ -536,11 +510,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Developer Experience',
description: 'TypeScript type safety',
steps: [
'Step 1: TypeScript is configured',
'Step 2: Type definitions exist',
'Step 3: Code compiles without errors',
],
passes: true,
});
}
@@ -550,11 +519,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'UI/Design',
description: 'Tailwind CSS styling',
steps: [
'Step 1: Tailwind is configured',
'Step 2: Styles are applied',
'Step 3: Responsive design works',
],
passes: true,
});
}
@@ -564,11 +528,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Developer Experience',
description: 'Code quality tools',
steps: [
'Step 1: Linter is configured',
'Step 2: Code passes lint checks',
'Step 3: Formatting is consistent',
],
passes: true,
});
}
@@ -578,11 +537,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Platform',
description: 'Electron desktop application',
steps: [
'Step 1: Electron is configured',
'Step 2: Main process runs',
'Step 3: Renderer process loads',
],
passes: true,
});
}
@@ -592,11 +546,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Testing',
description: 'Playwright end-to-end testing',
steps: [
'Step 1: Playwright is configured',
'Step 2: E2E tests are defined',
'Step 3: Tests pass successfully',
],
passes: true,
});
}
@@ -610,11 +559,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Documentation',
description: 'Project documentation',
steps: [
'Step 1: README exists',
'Step 2: Documentation is comprehensive',
'Step 3: Setup instructions are clear',
],
passes: true,
});
}
@@ -629,11 +573,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'DevOps',
description: 'CI/CD pipeline configuration',
steps: [
'Step 1: CI config exists',
'Step 2: Pipeline runs on push',
'Step 3: Automated checks pass',
],
passes: true,
});
}
@@ -647,11 +586,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Backend',
description: 'API endpoints',
steps: [
'Step 1: API routes are defined',
'Step 2: Endpoints respond correctly',
'Step 3: Error handling is implemented',
],
passes: true,
});
}
@@ -669,11 +603,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Architecture',
description: 'State management system',
steps: [
'Step 1: Store is configured',
'Step 2: State updates correctly',
'Step 3: Components access state',
],
passes: true,
});
}
@@ -683,11 +612,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Configuration',
description: 'Project configuration files',
steps: [
'Step 1: Config files exist',
'Step 2: Configuration is valid',
'Step 3: Build process works',
],
passes: true,
});
}
@@ -700,11 +624,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Core',
description: 'Basic project structure',
steps: [
'Step 1: Project directory exists',
'Step 2: Files are present',
'Step 3: Project can be loaded',
],
passes: true,
});
}
@@ -719,7 +638,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
id: crypto.randomUUID(),
category: detectedFeature.category,
description: detectedFeature.description,
steps: detectedFeature.steps,
status: 'backlog',
});
}

View File

@@ -436,9 +436,9 @@ export function BoardView() {
// Create the feature
const featureData = {
title: `Address PR #${prNumber} Review Comments`,
category: 'PR Review',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
@@ -478,9 +478,9 @@ export function BoardView() {
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts`,
category: 'Maintenance',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,

View File

@@ -128,116 +128,130 @@ export function AgentInfoPanel({
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
return (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
agentInfo.currentPhase === 'planning' &&
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
agentInfo.currentPhase === 'action' &&
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
agentInfo.currentPhase === 'verification' &&
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
)}
>
{agentInfo.currentPhase}
<>
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
)}
</div>
{/* Task List Progress */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
</div>
</div>
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate font-medium">Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
{agentInfo.currentPhase && (
<div
className={cn(
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
agentInfo.currentPhase === 'planning' &&
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
agentInfo.currentPhase === 'action' &&
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
agentInfo.currentPhase === 'verification' &&
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
)}
>
{agentInfo.currentPhase}
</div>
)}
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</div>
{/* Task List Progress */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
</div>
)}
</>
)}
</div>
</div>
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate font-medium">Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</span>
)}
</div>
)}
</>
)}
</div>
{/* SummaryDialog must be rendered alongside the expand button */}
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
</>
);
}

View File

@@ -1,17 +1,12 @@
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
interface CardContentSectionsProps {
feature: Feature;
useWorktrees: boolean;
showSteps: boolean;
}
export function CardContentSections({
feature,
useWorktrees,
showSteps,
}: CardContentSectionsProps) {
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
@@ -50,30 +45,6 @@ export function CardContentSections({
</div>
);
})()}
{/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1.5">
{feature.steps.slice(0, 3).map((step, index) => (
<div
key={index}
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === 'verified' ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-5">
+{feature.steps.length - 3} more
</p>
)}
</div>
)}
</>
);
}

View File

@@ -61,9 +61,7 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
const { useWorktrees } = useAppStore();
const isDraggable =
feature.status === 'backlog' ||
@@ -152,7 +150,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
{/* Agent Info Panel */}
<AgentInfoPanel

View File

@@ -62,7 +62,6 @@ interface AddFeatureDialogProps {
title: string;
category: string;
description: string;
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
@@ -105,7 +104,6 @@ export function AddFeatureDialog({
title: '',
category: '',
description: '',
steps: [''],
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
@@ -193,7 +191,6 @@ export function AddFeatureDialog({
title: newFeature.title,
category,
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
@@ -211,7 +208,6 @@ export function AddFeatureDialog({
title: '',
category: '',
description: '',
steps: [''],
images: [],
imagePaths: [],
textFilePaths: [],
@@ -502,8 +498,6 @@ export function AddFeatureDialog({
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
steps={newFeature.steps}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
</TabsContent>
</Tabs>

View File

@@ -64,7 +64,6 @@ interface EditFeatureDialogProps {
title: string;
category: string;
description: string;
steps: string[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
@@ -165,7 +164,6 @@ export function EditFeatureDialog({
title: editingFeature.title ?? '',
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
skipTests: editingFeature.skipTests ?? false,
model: selectedModel,
thinkingLevel: normalizedThinking,
@@ -491,8 +489,6 @@ export function EditFeatureDialog({
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
steps={editingFeature.steps}
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
testIdPrefix="edit"
/>
</TabsContent>

View File

@@ -245,7 +245,6 @@ export function FeatureSuggestionsDialog({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
steps: s.steps,
status: 'backlog' as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
@@ -453,23 +452,9 @@ export function FeatureSuggestionsDialog({
{suggestion.description}
</Label>
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
)}
{suggestion.steps.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Implementation Steps:
</p>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
{suggestion.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
{isExpanded && suggestion.reasoning && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
</div>
)}
</div>

View File

@@ -89,7 +89,6 @@ export function useBoardActions({
title: string;
category: string;
description: string;
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
@@ -208,7 +207,6 @@ export function useBoardActions({
title: string;
category: string;
description: string;
steps: string[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;

View File

@@ -1,36 +1,20 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FlaskConical, Plus } from 'lucide-react';
import { FlaskConical } from 'lucide-react';
interface TestingTabContentProps {
skipTests: boolean;
onSkipTestsChange: (skipTests: boolean) => void;
steps: string[];
onStepsChange: (steps: string[]) => void;
testIdPrefix?: string;
}
export function TestingTabContent({
skipTests,
onSkipTestsChange,
steps,
onStepsChange,
testIdPrefix = '',
}: TestingTabContentProps) {
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : 'skip-tests';
const handleStepChange = (index: number, value: string) => {
const newSteps = [...steps];
newSteps[index] = value;
onStepsChange(newSteps);
};
const handleAddStep = () => {
onStepsChange([...steps, '']);
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
@@ -48,37 +32,9 @@ export function TestingTabContent({
</div>
</div>
<p className="text-xs text-muted-foreground">
When enabled, this feature will use automated TDD. When disabled, it will require manual
verification.
When enabled, the agent will use Playwright to verify the feature works correctly before
marking it as verified. When disabled, manual verification will be required.
</p>
{/* Verification Steps - Only shown when skipTests is enabled */}
{skipTests && (
<div className="space-y-2 pt-2 border-t border-border">
<Label>Verification Steps</Label>
<p className="text-xs text-muted-foreground mb-2">
Add manual steps to verify this feature works correctly.
</p>
{steps.map((step, index) => (
<Input
key={index}
value={step}
placeholder={`Verification step ${index + 1}`}
onChange={(e) => handleStepChange(index, e.target.value)}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}feature-step-${index}${testIdPrefix ? '' : '-input'}`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddStep}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}add-step-button`}
>
<Plus className="w-4 h-4 mr-2" />
Add Verification Step
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useState, useEffect, useCallback } from 'react';
import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function GitHubIssuesView() {
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const { currentProject } = useAppStore();
const fetchIssues = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
try {
setError(null);
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listIssues(currentProject.path);
if (result.success) {
setOpenIssues(result.openIssues || []);
setClosedIssues(result.closedIssues || []);
} else {
setError(result.error || 'Failed to fetch issues');
}
}
} catch (err) {
console.error('[GitHubIssuesView] Error fetching issues:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [currentProject?.path]);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchIssues();
}, [fetchIssues]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
api.openExternalLink(url);
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<CircleDot className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Failed to Load Issues</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
);
}
const totalIssues = openIssues.length + closedIssues.length;
return (
<div className="flex-1 flex overflow-hidden">
{/* Issues List */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedIssue ? 'w-80' : 'flex-1'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">
{totalIssues === 0
? 'No issues found'
: `${openIssues.length} open, ${closedIssues.length} closed`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
{/* Issues List */}
<div className="flex-1 overflow-auto">
{totalIssues === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<CircleDot className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-medium mb-2">No Issues</h2>
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
</div>
) : (
<div className="divide-y divide-border">
{/* Open Issues */}
{openIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}
isSelected={selectedIssue?.number === issue.number}
onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate}
/>
))}
{/* Closed Issues Section */}
{closedIssues.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
Closed Issues ({closedIssues.length})
</div>
{closedIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}
isSelected={selectedIssue?.number === issue.number}
onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate}
/>
))}
</>
)}
</div>
)}
</div>
</div>
{/* Issue Detail Panel */}
{selectedIssue && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
{selectedIssue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 flex-shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{selectedIssue.number} {selectedIssue.title}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedIssue.url)}
>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedIssue(null)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Issue Detail Content */}
<div className="flex-1 overflow-auto p-6">
{/* Title */}
<h1 className="text-xl font-bold mb-2">{selectedIssue.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
selectedIssue.state === 'OPEN'
? 'bg-green-500/10 text-green-500'
: 'bg-purple-500/10 text-purple-500'
)}
>
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'}
</span>
<span>
#{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '}
<span className="font-medium text-foreground">{selectedIssue.author.login}</span>
</span>
</div>
{/* Labels */}
{selectedIssue.labels.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
{selectedIssue.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
{/* Body */}
{selectedIssue.body ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<div className="whitespace-pre-wrap text-sm">{selectedIssue.body}</div>
</div>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View comments, add reactions, and more on GitHub.
</p>
<Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full Issue on GitHub
</Button>
</div>
</div>
</div>
)}
</div>
);
}
interface IssueRowProps {
issue: GitHubIssue;
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
formatDate: (date: string) => string;
}
function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) {
return (
<div
className={cn(
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent'
)}
onClick={onClick}
>
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{issue.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted-foreground">
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
</span>
</div>
{issue.labels.length > 0 && (
<div className="flex items-center gap-1 mt-2 flex-wrap">
{issue.labels.map((label) => (
<span
key={label.name}
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,421 @@
import { useState, useEffect, useCallback } from 'react';
import {
GitPullRequest,
Loader2,
RefreshCw,
ExternalLink,
GitMerge,
Circle,
X,
AlertCircle,
} from 'lucide-react';
import { getElectronAPI, GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function GitHubPRsView() {
const [openPRs, setOpenPRs] = useState<GitHubPR[]>([]);
const [mergedPRs, setMergedPRs] = useState<GitHubPR[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
const { currentProject } = useAppStore();
const fetchPRs = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
try {
setError(null);
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listPRs(currentProject.path);
if (result.success) {
setOpenPRs(result.openPRs || []);
setMergedPRs(result.mergedPRs || []);
} else {
setError(result.error || 'Failed to fetch pull requests');
}
}
} catch (err) {
console.error('[GitHubPRsView] Error fetching PRs:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pull requests');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [currentProject?.path]);
useEffect(() => {
fetchPRs();
}, [fetchPRs]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchPRs();
}, [fetchPRs]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
api.openExternalLink(url);
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const getReviewStatus = (pr: GitHubPR) => {
if (pr.isDraft) return { label: 'Draft', color: 'text-muted-foreground', bg: 'bg-muted' };
switch (pr.reviewDecision) {
case 'APPROVED':
return { label: 'Approved', color: 'text-green-500', bg: 'bg-green-500/10' };
case 'CHANGES_REQUESTED':
return { label: 'Changes requested', color: 'text-orange-500', bg: 'bg-orange-500/10' };
case 'REVIEW_REQUIRED':
return { label: 'Review required', color: 'text-yellow-500', bg: 'bg-yellow-500/10' };
default:
return null;
}
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<GitPullRequest className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
);
}
const totalPRs = openPRs.length + mergedPRs.length;
return (
<div className="flex-1 flex overflow-hidden">
{/* PR List */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedPR ? 'w-80' : 'flex-1'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10">
<GitPullRequest className="h-5 w-5 text-blue-500" />
</div>
<div>
<h1 className="text-lg font-bold">Pull Requests</h1>
<p className="text-xs text-muted-foreground">
{totalPRs === 0
? 'No pull requests found'
: `${openPRs.length} open, ${mergedPRs.length} merged`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
{/* PR List */}
<div className="flex-1 overflow-auto">
{totalPRs === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<GitPullRequest className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-medium mb-2">No Pull Requests</h2>
<p className="text-sm text-muted-foreground">
This repository has no pull requests yet.
</p>
</div>
) : (
<div className="divide-y divide-border">
{/* Open PRs */}
{openPRs.map((pr) => (
<PRRow
key={pr.number}
pr={pr}
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
))}
{/* Merged PRs Section */}
{mergedPRs.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
Merged ({mergedPRs.length})
</div>
{mergedPRs.map((pr) => (
<PRRow
key={pr.number}
pr={pr}
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
))}
</>
)}
</div>
)}
</div>
</div>
{/* PR Detail Panel */}
{selectedPR && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
{selectedPR.state === 'MERGED' ? (
<GitMerge className="h-4 w-4 text-purple-500 flex-shrink-0" />
) : (
<GitPullRequest className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{selectedPR.number} {selectedPR.title}
</span>
{selectedPR.isDraft && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
Draft
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedPR.url)}
>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* PR Detail Content */}
<div className="flex-1 overflow-auto p-6">
{/* Title */}
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
selectedPR.state === 'MERGED'
? 'bg-purple-500/10 text-purple-500'
: selectedPR.isDraft
? 'bg-muted text-muted-foreground'
: 'bg-green-500/10 text-green-500'
)}
>
{selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'}
</span>
{getReviewStatus(selectedPR) && (
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
getReviewStatus(selectedPR)!.bg,
getReviewStatus(selectedPR)!.color
)}
>
{getReviewStatus(selectedPR)!.label}
</span>
)}
<span>
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
</span>
</div>
{/* Branch info */}
{selectedPR.headRefName && (
<div className="flex items-center gap-2 mb-4">
<span className="text-xs text-muted-foreground">Branch:</span>
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{selectedPR.headRefName}
</span>
</div>
)}
{/* Labels */}
{selectedPR.labels.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
{selectedPR.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
{/* Body */}
{selectedPR.body ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<div className="whitespace-pre-wrap text-sm">{selectedPR.body}</div>
</div>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View code changes, comments, and reviews on GitHub.
</p>
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full PR on GitHub
</Button>
</div>
</div>
</div>
)}
</div>
);
}
interface PRRowProps {
pr: GitHubPR;
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
formatDate: (date: string) => string;
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
}
function PRRow({
pr,
isSelected,
onClick,
onOpenExternal,
formatDate,
getReviewStatus,
}: PRRowProps) {
const reviewStatus = getReviewStatus(pr);
return (
<div
className={cn(
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent'
)}
onClick={onClick}
>
{pr.state === 'MERGED' ? (
<GitMerge className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
) : (
<GitPullRequest className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{pr.title}</span>
{pr.isDraft && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground flex-shrink-0">
Draft
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted-foreground">
#{pr.number} opened {formatDate(pr.createdAt)} by {pr.author.login}
</span>
{pr.headRefName && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-1 rounded">
{pr.headRefName}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* Review Status */}
{reviewStatus && (
<span
className={cn(
'px-1.5 py-0.5 text-[10px] font-medium rounded',
reviewStatus.bg,
reviewStatus.color
)}
>
{reviewStatus.label}
</span>
)}
{/* Labels */}
{pr.labels.map((label) => (
<span
key={label.name}
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -345,11 +345,6 @@ export function InterviewView() {
category: 'Core',
description: 'Initial project setup',
status: 'backlog' as const,
steps: [
'Step 1: Review app_spec.txt',
'Step 2: Set up development environment',
'Step 3: Start implementing features',
],
skipTests: true,
};

View File

@@ -92,12 +92,77 @@ export interface RunningAgentsAPI {
getAll: () => Promise<RunningAgentsResult>;
}
// GitHub types
export interface GitHubLabel {
name: string;
color: string;
}
export interface GitHubAuthor {
login: string;
}
export interface GitHubIssue {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
body: string;
}
export interface GitHubPR {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
isDraft: boolean;
headRefName: string;
reviewDecision: string | null;
mergeable: string;
body: string;
}
export interface GitHubRemoteStatus {
hasGitHubRemote: boolean;
remoteUrl: string | null;
owner: string | null;
repo: string | null;
}
export interface GitHubAPI {
checkRemote: (projectPath: string) => Promise<{
success: boolean;
hasGitHubRemote?: boolean;
remoteUrl?: string | null;
owner?: string | null;
repo?: string | null;
error?: string;
}>;
listIssues: (projectPath: string) => Promise<{
success: boolean;
openIssues?: GitHubIssue[];
closedIssues?: GitHubIssue[];
error?: string;
}>;
listPRs: (projectPath: string) => Promise<{
success: boolean;
openPRs?: GitHubPR[];
mergedPRs?: GitHubPR[];
error?: string;
}>;
}
// Feature Suggestions types
export interface FeatureSuggestion {
id: string;
category: string;
description: string;
steps: string[];
priority: number;
reasoning: string;
}
@@ -326,6 +391,7 @@ export interface ElectronAPI {
autoMode?: AutoModeAPI;
features?: FeaturesAPI;
runningAgents?: RunningAgentsAPI;
github?: GitHubAPI;
enhancePrompt?: {
enhance: (
originalText: string,
@@ -873,6 +939,9 @@ const getMockElectronAPI = (): ElectronAPI => {
// Mock Running Agents API
runningAgents: createMockRunningAgentsAPI(),
// Mock GitHub API
github: createMockGitHubAPI(),
// Mock Claude API
claude: {
getUsage: async () => {
@@ -1975,12 +2044,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'Code Smell',
description: 'Extract duplicate validation logic into reusable utility',
steps: [
'Identify all files with similar validation patterns',
'Create a validation utilities module',
'Replace duplicate code with utility calls',
'Add unit tests for the new utilities',
],
priority: 1,
reasoning: 'Reduces code duplication and improves maintainability',
},
@@ -1988,12 +2051,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Complexity',
description: 'Break down large handleSubmit function into smaller functions',
steps: [
'Identify the handleSubmit function in form components',
'Extract validation logic into separate function',
'Extract API call logic into separate function',
'Extract success/error handling into separate functions',
],
priority: 2,
reasoning: 'Function is too long and handles multiple responsibilities',
},
@@ -2001,12 +2058,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Architecture',
description: 'Move business logic out of React components into hooks',
steps: [
'Identify business logic in component files',
'Create custom hooks for reusable logic',
'Update components to use the new hooks',
'Add tests for the extracted hooks',
],
priority: 3,
reasoning: 'Improves separation of concerns and testability',
},
@@ -2019,12 +2070,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'High',
description: 'Sanitize user input before rendering to prevent XSS',
steps: [
'Audit all places where user input is rendered',
'Implement input sanitization using DOMPurify',
'Add Content-Security-Policy headers',
'Test with common XSS payloads',
],
priority: 1,
reasoning: 'User input is rendered without proper sanitization',
},
@@ -2032,12 +2077,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Medium',
description: 'Add rate limiting to authentication endpoints',
steps: [
'Implement rate limiting middleware',
'Configure limits for login attempts',
'Add account lockout after failed attempts',
'Log suspicious activity',
],
priority: 2,
reasoning: 'Prevents brute force attacks on authentication',
},
@@ -2045,12 +2084,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Low',
description: 'Remove sensitive information from error messages',
steps: [
'Audit error handling in API routes',
'Create generic error messages for production',
'Log detailed errors server-side only',
'Implement proper error boundaries',
],
priority: 3,
reasoning: 'Error messages may leak implementation details',
},
@@ -2063,12 +2096,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'Rendering',
description: 'Add React.memo to prevent unnecessary re-renders',
steps: [
'Profile component renders with React DevTools',
'Identify components that re-render unnecessarily',
'Wrap pure components with React.memo',
'Use useCallback for event handlers passed as props',
],
priority: 1,
reasoning: "Components re-render even when props haven't changed",
},
@@ -2076,12 +2103,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Bundle Size',
description: 'Implement code splitting for route components',
steps: [
'Use React.lazy for route components',
'Add Suspense boundaries with loading states',
'Analyze bundle with webpack-bundle-analyzer',
'Consider dynamic imports for heavy libraries',
],
priority: 2,
reasoning: 'Initial bundle is larger than necessary',
},
@@ -2089,12 +2110,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Caching',
description: 'Add memoization for expensive computations',
steps: [
'Identify expensive calculations in render',
'Use useMemo for derived data',
'Consider using react-query for server state',
'Add caching headers for static assets',
],
priority: 3,
reasoning: 'Expensive computations run on every render',
},
@@ -2107,12 +2122,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'User Experience',
description: 'Add dark mode toggle with system preference detection',
steps: [
'Create a ThemeProvider context to manage theme state',
'Add a toggle component in the settings or header',
'Implement CSS variables for theme colors',
'Add localStorage persistence for user preference',
],
priority: 1,
reasoning: 'Dark mode is a standard feature that improves accessibility and user comfort',
},
@@ -2120,11 +2129,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Performance',
description: 'Implement lazy loading for heavy components',
steps: [
'Identify components that are heavy or rarely used',
'Use React.lazy() and Suspense for code splitting',
'Add loading states for lazy-loaded components',
],
priority: 2,
reasoning: 'Improves initial load time and reduces bundle size',
},
@@ -2132,12 +2136,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Accessibility',
description: 'Add keyboard navigation support throughout the app',
steps: [
'Implement focus management for modals and dialogs',
'Add keyboard shortcuts for common actions',
'Ensure all interactive elements are focusable',
'Add ARIA labels and roles where needed',
],
priority: 3,
reasoning: 'Improves accessibility for users who rely on keyboard navigation',
},
@@ -2604,6 +2602,38 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
};
}
// Mock GitHub API implementation
function createMockGitHubAPI(): GitHubAPI {
return {
checkRemote: async (projectPath: string) => {
console.log('[Mock] Checking GitHub remote for:', projectPath);
return {
success: true,
hasGitHubRemote: false,
remoteUrl: null,
owner: null,
repo: null,
};
},
listIssues: async (projectPath: string) => {
console.log('[Mock] Listing GitHub issues for:', projectPath);
return {
success: true,
openIssues: [],
closedIssues: [],
};
},
listPRs: async (projectPath: string) => {
console.log('[Mock] Listing GitHub PRs for:', projectPath);
return {
success: true,
openPRs: [],
mergedPRs: [],
};
},
};
}
// Utility functions for project management
export interface Project {

View File

@@ -21,6 +21,9 @@ import type {
SuggestionsEvent,
SpecRegenerationEvent,
SuggestionType,
GitHubAPI,
GitHubIssue,
GitHubPR,
} from './electron';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
@@ -743,6 +746,13 @@ export class HttpApiClient implements ElectronAPI {
}> => this.get('/api/running-agents'),
};
// GitHub API
github: GitHubAPI = {
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
};
// Workspace API
workspace = {
getConfig: (): Promise<{

View File

@@ -52,3 +52,14 @@ export function pathsEqual(p1: string | undefined | null, p2: string | undefined
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}
/**
* Detect if running on macOS.
* Checks Electron process.platform first, then falls back to navigator APIs.
*/
export const isMac =
typeof process !== 'undefined' && process.platform === 'darwin'
? true
: typeof navigator !== 'undefined' &&
(/Mac/.test(navigator.userAgent) ||
(navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false));

View File

@@ -10,6 +10,7 @@ import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import http, { Server } from 'http';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
// Development environment
const isDev = !app.isPackaged;
@@ -274,12 +275,22 @@ async function startStaticServer(): Promise<void> {
* Start the backend server
*/
async function startServer(): Promise<void> {
let command: string;
// Find Node.js executable (handles desktop launcher scenarios)
const nodeResult = findNodeExecutable({
skipSearch: isDev,
logger: (msg: string) => console.log(`[Electron] ${msg}`),
});
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
if (command !== 'node' && !fs.existsSync(command)) {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
}
let args: string[];
let serverPath: string;
if (isDev) {
command = 'node';
serverPath = path.join(__dirname, '../../server/src/index.ts');
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
@@ -302,7 +313,6 @@ async function startServer(): Promise<void> {
args = [tsxCliPath, 'watch', serverPath];
} else {
command = 'node';
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
args = [serverPath];
@@ -315,8 +325,15 @@ async function startServer(): Promise<void> {
? path.join(process.resourcesPath, 'server', 'node_modules')
: path.join(__dirname, '../../server/node_modules');
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
console.log(`[Electron] Enhanced PATH with Node directory: ${path.dirname(command)}`);
}
const env = {
...process.env,
PATH: enhancedPath,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules,
@@ -511,6 +528,16 @@ app.whenReady().then(async () => {
createWindow();
} catch (error) {
console.error('[Electron] Failed to start:', error);
const errorMessage = (error as Error).message;
const isNodeError = errorMessage.includes('Node.js');
dialog.showErrorBox(
'Automaker Failed to Start',
`The application failed to start.\n\n${errorMessage}\n\n${
isNodeError
? 'Please install Node.js from https://nodejs.org or via a package manager (Homebrew, nvm, fnm).'
: 'Please check the application logs for more details.'
}`
);
app.quit();
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { GitHubIssuesView } from '@/components/views/github-issues-view';
export const Route = createFileRoute('/github-issues')({
component: GitHubIssuesView,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { GitHubPRsView } from '@/components/views/github-prs-view';
export const Route = createFileRoute('/github-prs')({
component: GitHubPRsView,
});

View File

@@ -4,13 +4,9 @@ import type { Project, TrashedProject } from '@/lib/electron';
import type {
Feature as BaseFeature,
FeatureImagePath,
FeatureTextFilePath,
AgentModel,
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
ThemeMode,
} from '@automaker/types';
// Re-export ThemeMode for convenience
@@ -245,17 +241,6 @@ export interface ChatSession {
archived: boolean;
}
// Re-export for backward compatibility
export type {
FeatureImagePath,
FeatureTextFilePath,
AgentModel,
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
};
// UI-specific: base64-encoded images (not in shared types)
export interface FeatureImage {
id: string;
@@ -265,18 +250,18 @@ export interface FeatureImage {
size: number;
}
export interface FeatureImagePath {
id: string;
path: string; // Path to the temp file
filename: string;
mimeType: string;
}
// Available models for feature execution
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
// UI-specific Feature extension with UI-only fields and stricter types
export interface Feature extends Omit<
BaseFeature,
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
> {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps: string[]; // Required in UI (not optional)
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
images?: FeatureImage[]; // UI-specific base64 images

View File

@@ -0,0 +1,306 @@
# E2E Testing Guide
Best practices and patterns for writing reliable, non-flaky Playwright e2e tests in this codebase.
## Core Principles
1. **No arbitrary timeouts** - Never use `page.waitForTimeout()`. Always wait for specific conditions.
2. **Use data-testid attributes** - Prefer `[data-testid="..."]` selectors over CSS classes or text content.
3. **Clean up after tests** - Use unique temp directories and clean them up in `afterAll`.
4. **Test isolation** - Each test should be independent and not rely on state from other tests.
## Setting Up Test State
### Use Setup Utilities (Recommended)
Use the provided utility functions to set up localStorage state. These utilities hide the internal store structure and version details, making tests more maintainable.
```typescript
import { setupWelcomeView, setupRealProject } from './utils';
// Show welcome view with workspace directory configured
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
// Show welcome view with recent projects
await setupWelcomeView(page, {
workspaceDir: TEST_TEMP_DIR,
recentProjects: [
{
id: 'project-123',
name: 'My Project',
path: '/path/to/project',
lastOpened: new Date().toISOString(),
},
],
});
// Set up a real project on the filesystem
await setupRealProject(page, projectPath, projectName, {
setAsCurrent: true, // Opens board view (default)
});
```
### Why Use Utilities Instead of Raw localStorage
1. **Version management** - Store versions are centralized in one place
2. **Less brittle** - If store structure changes, update one file instead of every test
3. **Cleaner tests** - Focus on test logic, not setup boilerplate
4. **Type safety** - Utilities provide typed interfaces for test data
### Manual LocalStorage Setup (Advanced)
If you need custom setup not covered by utilities, use `page.addInitScript()`.
Store versions are defined in `tests/utils/project/setup.ts`:
- `APP_STORE`: version 2 (matches `app-store.ts`)
- `SETUP_STORE`: version 0 (matches `setup-store.ts` default)
### Temp Directory Management
Create unique temp directories for test isolation:
```typescript
import { createTempDirPath, cleanupTempDir } from './utils';
const TEST_TEMP_DIR = createTempDirPath('my-test-name');
test.describe('My Tests', () => {
test.beforeAll(async () => {
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.afterAll(async () => {
cleanupTempDir(TEST_TEMP_DIR);
});
});
```
## Waiting for Elements
### Prefer `toBeVisible()` over `waitForSelector()`
```typescript
// Good - uses Playwright's auto-waiting with expect
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
// Avoid - manual waiting
await page.waitForSelector('[data-testid="welcome-view"]');
```
### Wait for network idle after navigation
```typescript
await page.goto('/');
await page.waitForLoadState('networkidle');
```
### Use appropriate timeouts
- Quick UI updates: 5000ms (default)
- Page loads/navigation: 10000ms
- Async operations (API calls, file system): 15000ms
```typescript
// Fast UI element
await expect(button).toBeVisible();
// Page load
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
// Async operation completion
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
```
## Element Selection
### Use data-testid attributes
```typescript
// Good - stable selector
const button = page.locator('[data-testid="create-new-project"]');
// Avoid - brittle selectors
const button = page.locator('.btn-primary');
const button = page.getByText('Create');
```
### Scope selectors when needed
When text appears in multiple places, scope to a parent:
```typescript
// Bad - might match multiple elements
await expect(page.getByText(projectName)).toBeVisible();
// Good - scoped to specific container
await expect(page.locator('[data-testid="project-selector"]').getByText(projectName)).toBeVisible();
```
### Handle strict mode violations
If a selector matches multiple elements:
```typescript
// Use .first() if you need the first match
await page.locator('[data-testid="item"]').first().click();
// Or scope to a unique parent
await page.locator('[data-testid="sidebar"]').locator('[data-testid="item"]').click();
```
## Clicking Elements
### Always verify visibility before clicking
```typescript
const button = page.locator('[data-testid="submit"]');
await expect(button).toBeVisible();
await button.click();
```
### Handle dialogs that may close quickly
Some dialogs may appear briefly or auto-close. Don't rely on clicking them:
```typescript
// Instead of trying to close a dialog that might disappear:
// await expect(dialog).toBeVisible();
// await closeButton.click(); // May fail if dialog closes first
// Just verify the end state:
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
```
## Filesystem Verification
Verify files were created after async operations:
```typescript
// Wait for UI to confirm operation completed first
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Then verify filesystem
const projectPath = path.join(TEST_TEMP_DIR, projectName);
expect(fs.existsSync(projectPath)).toBe(true);
const appSpecPath = path.join(projectPath, '.automaker', 'app_spec.txt');
expect(fs.existsSync(appSpecPath)).toBe(true);
const content = fs.readFileSync(appSpecPath, 'utf-8');
expect(content).toContain(projectName);
```
## Test Structure
### Use descriptive test names
```typescript
test('should create a new blank project from welcome view', async ({ page }) => {
// ...
});
```
### Group related tests with describe blocks
```typescript
test.describe('Project Creation', () => {
test('should create a new blank project from welcome view', ...);
test('should create a project from template', ...);
});
```
### Use serial mode when tests depend on each other
```typescript
test.describe.configure({ mode: 'serial' });
```
## Common Patterns
### Waiting for either of two outcomes
When multiple outcomes are possible (e.g., dialog or direct navigation):
```typescript
// Wait for either the dialog or the board view
await Promise.race([
initDialog.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}),
boardView.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}),
]);
// Then handle whichever appeared
if (await initDialog.isVisible()) {
await closeButton.click();
}
await expect(boardView).toBeVisible();
```
### Generating unique test data
```typescript
const projectName = `test-project-${Date.now()}`;
```
## Running Tests
```bash
# Run all tests
npm run test
# Run specific test file
npm run test -- project-creation.spec.ts
# Run with headed browser (see what's happening)
npm run test:headed -- project-creation.spec.ts
# Run multiple times to check for flakiness
npm run test -- project-creation.spec.ts --repeat-each=5
```
## Debugging Failed Tests
1. Check the screenshot in `test-results/`
2. Read the error context markdown file in `test-results/`
3. Run with `--headed` to watch the test
4. Add `await page.pause()` to pause execution at a specific point
## Available Test Utilities
Import from `./utils`:
### State Setup Utilities
- `setupWelcomeView(page, options?)` - Set up empty state showing welcome view
- `options.workspaceDir` - Pre-configure workspace directory
- `options.recentProjects` - Add projects to recent list (not current)
- `setupRealProject(page, path, name, options?)` - Set up state with a real filesystem project
- `options.setAsCurrent` - Open board view (default: true)
- `options.additionalProjects` - Add more projects to list
- `setupMockProject(page)` - Set up mock project for unit-style tests
- `setupComplete(page)` - Mark setup wizard as complete
### Filesystem Utilities
- `createTempDirPath(prefix)` - Create unique temp directory path
- `cleanupTempDir(path)` - Remove temp directory
- `createTestGitRepo(tempDir)` - Create a git repo for testing
### Waiting Utilities
- `waitForNetworkIdle(page)` - Wait for network to be idle
- `waitForElement(page, testId)` - Wait for element by test ID
### Async File Verification
Use `expect().toPass()` for polling filesystem operations:
```typescript
await expect(async () => {
expect(fs.existsSync(filePath)).toBe(true);
}).toPass({ timeout: 10000 });
```
See `tests/utils/index.ts` for the full list of available utilities.

View File

@@ -0,0 +1,125 @@
/**
* Open Project End-to-End Test
*
* Tests opening an existing project directory from the welcome view.
* This verifies that:
* 1. An existing directory can be opened as a project
* 2. The .automaker directory is initialized if it doesn't exist
* 3. The project is loaded and shown in the board view
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils';
// Create unique temp dir for this test run
const TEST_TEMP_DIR = createTempDirPath('open-project-test');
test.describe('Open Project', () => {
test.beforeAll(async () => {
// Create test temp directory
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.afterAll(async () => {
// Cleanup temp directory
cleanupTempDir(TEST_TEMP_DIR);
});
test('should open an existing project directory from recent projects', async ({ page }) => {
const projectName = `existing-project-${Date.now()}`;
const projectPath = path.join(TEST_TEMP_DIR, projectName);
const projectId = `project-${Date.now()}`;
// Create the project directory with some files to simulate an existing codebase
fs.mkdirSync(projectPath, { recursive: true });
// Create a package.json to simulate a real project
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify(
{
name: projectName,
version: '1.0.0',
description: 'A test project for e2e testing',
},
null,
2
)
);
// Create a README.md
fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${projectName}\n\nA test project.`);
// Create a src directory with an index.ts file
fs.mkdirSync(path.join(projectPath, 'src'), { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'src', 'index.ts'),
'export const hello = () => console.log("Hello World");'
);
// Set up welcome view with the project in recent projects (but NOT as current project)
await setupWelcomeView(page, {
recentProjects: [
{
id: projectId,
name: projectName,
path: projectPath,
lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
},
],
});
// Navigate to the app
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for welcome view to be visible
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
// Verify we see the "Recent Projects" section
await expect(page.getByText('Recent Projects')).toBeVisible({ timeout: 5000 });
// Click on the recent project to open it
const recentProjectCard = page.locator(`[data-testid="recent-project-${projectId}"]`);
await expect(recentProjectCard).toBeVisible();
await recentProjectCard.click();
// Wait for the board view to appear (project was opened)
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Verify the project name appears in the project selector (sidebar)
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectName)
).toBeVisible({ timeout: 5000 });
// Verify .automaker directory was created (initialized for the first time)
// Use polling since file creation may be async
const automakerDir = path.join(projectPath, '.automaker');
await expect(async () => {
expect(fs.existsSync(automakerDir)).toBe(true);
}).toPass({ timeout: 10000 });
// Verify the required structure was created by initializeProject:
// - .automaker/categories.json
// - .automaker/features directory
// - .automaker/context directory
// Note: app_spec.txt is NOT created automatically for existing projects
const categoriesPath = path.join(automakerDir, 'categories.json');
await expect(async () => {
expect(fs.existsSync(categoriesPath)).toBe(true);
}).toPass({ timeout: 10000 });
// Verify subdirectories were created
expect(fs.existsSync(path.join(automakerDir, 'features'))).toBe(true);
expect(fs.existsSync(path.join(automakerDir, 'context'))).toBe(true);
// Verify the original project files still exist (weren't modified)
expect(fs.existsSync(path.join(projectPath, 'package.json'))).toBe(true);
expect(fs.existsSync(path.join(projectPath, 'README.md'))).toBe(true);
expect(fs.existsSync(path.join(projectPath, 'src', 'index.ts'))).toBe(true);
});
});

View File

@@ -0,0 +1,188 @@
/**
* Project Creation End-to-End Tests
*
* Tests the project creation flows:
* 1. Creating a new blank project from the welcome view
* 2. Creating a new project from a GitHub template
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils';
// Create unique temp dir for this test run
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
test.describe('Project Creation', () => {
test.beforeAll(async () => {
// Create test temp directory
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.afterAll(async () => {
// Cleanup temp directory
cleanupTempDir(TEST_TEMP_DIR);
});
test('should create a new blank project from welcome view', async ({ page }) => {
const projectName = `test-project-${Date.now()}`;
// Set up welcome view with workspace directory pre-configured
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
// Navigate to the app
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for welcome view to be visible
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
// Click the "Create New Project" dropdown button
const createButton = page.locator('[data-testid="create-new-project"]');
await expect(createButton).toBeVisible();
await createButton.click();
// Click "Quick Setup" option from the dropdown
const quickSetupOption = page.locator('[data-testid="quick-setup-option"]');
await expect(quickSetupOption).toBeVisible();
await quickSetupOption.click();
// Wait for the new project modal to appear
const modal = page.locator('[data-testid="new-project-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Enter the project name
const projectNameInput = page.locator('[data-testid="project-name-input"]');
await expect(projectNameInput).toBeVisible();
await projectNameInput.fill(projectName);
// Verify the workspace directory is shown (from our pre-configured localStorage)
// Wait for workspace to be loaded (it shows "Will be created at:" when ready)
await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 });
// Click the Create Project button
const createProjectButton = page.locator('[data-testid="confirm-create-project"]');
await expect(createProjectButton).toBeVisible();
await createProjectButton.click();
// Wait for project creation to complete
// The app may show an init dialog briefly and then navigate to board view
// We just need to verify we end up on the board view with our project
// Wait for the board view - this confirms the project was created and opened
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Verify the project name appears in the project selector (sidebar)
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectName)
).toBeVisible({ timeout: 5000 });
// Verify the project was created in the filesystem
const projectPath = path.join(TEST_TEMP_DIR, projectName);
expect(fs.existsSync(projectPath)).toBe(true);
// Verify .automaker directory was created
const automakerDir = path.join(projectPath, '.automaker');
expect(fs.existsSync(automakerDir)).toBe(true);
// Verify app_spec.txt was created
const appSpecPath = path.join(automakerDir, 'app_spec.txt');
expect(fs.existsSync(appSpecPath)).toBe(true);
// Verify the app_spec.txt contains the project name
const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8');
expect(appSpecContent).toContain(projectName);
});
test('should create a new project from GitHub template', async ({ page }) => {
// Increase timeout for this test since git clone takes time
test.setTimeout(60000);
const projectName = `template-project-${Date.now()}`;
// Set up welcome view with workspace directory pre-configured
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
// Navigate to the app
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for welcome view to be visible
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
// Click the "Create New Project" dropdown button
const createButton = page.locator('[data-testid="create-new-project"]');
await expect(createButton).toBeVisible();
await createButton.click();
// Click "Quick Setup" option from the dropdown
const quickSetupOption = page.locator('[data-testid="quick-setup-option"]');
await expect(quickSetupOption).toBeVisible();
await quickSetupOption.click();
// Wait for the new project modal to appear
const modal = page.locator('[data-testid="new-project-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Enter the project name first
const projectNameInput = page.locator('[data-testid="project-name-input"]');
await expect(projectNameInput).toBeVisible();
await projectNameInput.fill(projectName);
// Wait for workspace directory to be loaded
await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 });
// Click on the "Starter Kit" tab
const starterKitTab = modal.getByText('Starter Kit');
await expect(starterKitTab).toBeVisible();
await starterKitTab.click();
// Select the first template (Automaker Starter Kit)
const firstTemplate = page.locator('[data-testid="template-automaker-starter-kit"]');
await expect(firstTemplate).toBeVisible();
await firstTemplate.click();
// Verify the template is selected (check mark should appear)
await expect(firstTemplate.locator('.lucide-check')).toBeVisible();
// Click the Create Project button
const createProjectButton = page.locator('[data-testid="confirm-create-project"]');
await expect(createProjectButton).toBeVisible();
await createProjectButton.click();
// Wait for git clone to complete and board view to appear
// This takes longer due to the git clone operation
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 45000 });
// Verify the project name appears in the project selector (sidebar)
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectName)
).toBeVisible({ timeout: 5000 });
// Verify the project was cloned in the filesystem
const projectPath = path.join(TEST_TEMP_DIR, projectName);
expect(fs.existsSync(projectPath)).toBe(true);
// Verify .automaker directory was created
const automakerDir = path.join(projectPath, '.automaker');
expect(fs.existsSync(automakerDir)).toBe(true);
// Verify app_spec.txt was created with template info
const appSpecPath = path.join(automakerDir, 'app_spec.txt');
expect(fs.existsSync(appSpecPath)).toBe(true);
const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8');
expect(appSpecContent).toContain(projectName);
expect(appSpecContent).toContain('Automaker Starter Kit');
// Verify the template files were cloned (check for package.json which should exist in the template)
const packageJsonPath = path.join(projectPath, 'package.json');
expect(fs.existsSync(packageJsonPath)).toBe(true);
// Verify it's a git repository (cloned from GitHub)
const gitDir = path.join(projectPath, '.git');
expect(fs.existsSync(gitDir)).toBe(true);
});
});

View File

@@ -1,5 +1,164 @@
import { Page } from '@playwright/test';
/**
* Store version constants - centralized to avoid hardcoding across tests
* These MUST match the versions used in the actual stores
*/
const STORE_VERSIONS = {
APP_STORE: 2, // Must match app-store.ts persist version
SETUP_STORE: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0
} as const;
/**
* Project interface for test setup
*/
export interface TestProject {
id: string;
name: string;
path: string;
lastOpened?: string;
}
/**
* Options for setting up the welcome view
*/
export interface WelcomeViewSetupOptions {
/** Directory path to pre-configure as the workspace directory */
workspaceDir?: string;
/** Recent projects to show (but not as current project) */
recentProjects?: TestProject[];
}
/**
* Set up localStorage to show the welcome view with no current project
* This is the cleanest way to test project creation flows
*
* @param page - Playwright page
* @param options - Configuration options
*/
export async function setupWelcomeView(
page: Page,
options?: WelcomeViewSetupOptions
): Promise<void> {
await page.addInitScript(
({
opts,
versions,
}: {
opts: WelcomeViewSetupOptions | undefined;
versions: typeof STORE_VERSIONS;
}) => {
// Set up empty app state (no current project) - shows welcome view
const appState = {
state: {
projects: opts?.recentProjects || [],
currentProject: null,
currentView: 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: versions.APP_STORE,
};
localStorage.setItem('automaker-storage', JSON.stringify(appState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
skipClaudeSetup: false,
},
version: versions.SETUP_STORE,
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Set workspace directory if provided
if (opts?.workspaceDir) {
localStorage.setItem('automaker:lastProjectDir', opts.workspaceDir);
}
},
{ opts: options, versions: STORE_VERSIONS }
);
}
/**
* Set up localStorage with a project at a real filesystem path
* Use this when testing with actual files on disk
*
* @param page - Playwright page
* @param projectPath - Absolute path to the project directory
* @param projectName - Display name for the project
* @param options - Additional options
*/
export async function setupRealProject(
page: Page,
projectPath: string,
projectName: string,
options?: {
/** Set as current project (opens board view) or just add to recent projects */
setAsCurrent?: boolean;
/** Additional recent projects to include */
additionalProjects?: TestProject[];
}
): Promise<void> {
await page.addInitScript(
({
path,
name,
opts,
versions,
}: {
path: string;
name: string;
opts: typeof options;
versions: typeof STORE_VERSIONS;
}) => {
const projectId = `project-${Date.now()}`;
const project: TestProject = {
id: projectId,
name: name,
path: path,
lastOpened: new Date().toISOString(),
};
const allProjects = [project, ...(opts?.additionalProjects || [])];
const currentProject = opts?.setAsCurrent !== false ? project : null;
const appState = {
state: {
projects: allProjects,
currentProject: currentProject,
currentView: currentProject ? 'board' : 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: versions.APP_STORE,
};
localStorage.setItem('automaker-storage', JSON.stringify(appState));
// Mark setup as complete
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
skipClaudeSetup: false,
},
version: versions.SETUP_STORE,
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
},
{ path: projectPath, name: projectName, opts: options, versions: STORE_VERSIONS }
);
}
/**
* Set up a mock project in localStorage to bypass the welcome screen
* This simulates having opened a project before
@@ -595,7 +754,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
* Set up the app to skip the setup wizard (setup already complete)
*/
export async function setupComplete(page: Page): Promise<void> {
await page.addInitScript(() => {
await page.addInitScript((versions: typeof STORE_VERSIONS) => {
// Mark setup as complete
const setupState = {
state: {
@@ -604,11 +763,11 @@ export async function setupComplete(page: Page): Promise<void> {
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
version: versions.SETUP_STORE,
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
});
}, STORE_VERSIONS);
}
/**

View File

@@ -44,3 +44,11 @@ export {
// Secure file system (validates paths before I/O operations)
export * as secureFs from './secure-fs.js';
// Node.js executable finder (cross-platform)
export {
findNodeExecutable,
buildEnhancedPath,
type NodeFinderResult,
type NodeFinderOptions,
} from './node-finder.js';

View File

@@ -0,0 +1,386 @@
/**
* Cross-platform Node.js executable finder
*
* Handles finding Node.js when the app is launched from desktop environments
* (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */
const VERSION_DIR_PATTERN = /^v?\d+/;
/** Pattern to identify pre-release versions (beta, rc, alpha, nightly, canary) */
const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i;
/** Result of finding Node.js executable */
export interface NodeFinderResult {
/** Path to the Node.js executable */
nodePath: string;
/** How Node.js was found */
source:
| 'homebrew'
| 'system'
| 'nvm'
| 'fnm'
| 'nvm-windows'
| 'program-files'
| 'scoop'
| 'chocolatey'
| 'which'
| 'where'
| 'fallback';
}
/** Options for finding Node.js */
export interface NodeFinderOptions {
/** Skip the search and return 'node' immediately (useful for dev mode) */
skipSearch?: boolean;
/** Custom logger function */
logger?: (message: string) => void;
}
/**
* Check if a file exists and is executable
* On Windows, only checks existence (X_OK is not meaningful)
*/
function isExecutable(filePath: string): boolean {
try {
if (process.platform === 'win32') {
// On Windows, fs.constants.X_OK is not meaningful - just check existence
fs.accessSync(filePath, fs.constants.F_OK);
} else {
// On Unix-like systems, check for execute permission
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
} catch {
return false;
}
}
/**
* Find Node.js executable from version manager directories (NVM, fnm)
* Uses semantic version sorting to prefer the latest stable version
* Pre-release versions (beta, rc, alpha) are deprioritized but used as fallback
*/
function findNodeFromVersionManager(
basePath: string,
binSubpath: string = 'bin/node'
): string | null {
if (!fs.existsSync(basePath)) return null;
try {
const allVersions = fs
.readdirSync(basePath)
.filter((v) => VERSION_DIR_PATTERN.test(v))
// Semantic version sort - newest first using localeCompare with numeric option
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
// Separate stable and pre-release versions, preferring stable
const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v));
const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v));
// Try stable versions first, then fall back to pre-release
for (const version of [...stableVersions, ...preReleaseVersions]) {
const nodePath = path.join(basePath, version, binSubpath);
if (isExecutable(nodePath)) {
return nodePath;
}
}
} catch {
// Directory read failed, skip this location
}
return null;
}
/**
* Find Node.js on macOS
*/
function findNodeMacOS(homeDir: string): NodeFinderResult | null {
// Check Homebrew paths in order of preference
const homebrewPaths = [
// Apple Silicon
'/opt/homebrew/bin/node',
// Intel
'/usr/local/bin/node',
];
for (const nodePath of homebrewPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'homebrew' };
}
}
// System Node
if (isExecutable('/usr/bin/node')) {
return { nodePath: '/usr/bin/node', source: 'system' };
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
// fnm installation (multiple possible locations)
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
];
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath);
if (fnmNode) {
return { nodePath: fnmNode, source: 'fnm' };
}
}
return null;
}
/**
* Find Node.js on Linux
*/
function findNodeLinux(homeDir: string): NodeFinderResult | null {
// Common Linux paths
const systemPaths = [
'/usr/bin/node',
'/usr/local/bin/node',
// Snap installation
'/snap/bin/node',
];
for (const nodePath of systemPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'system' };
}
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
// fnm installation
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, '.fnm', 'node-versions'),
];
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath);
if (fnmNode) {
return { nodePath: fnmNode, source: 'fnm' };
}
}
return null;
}
/**
* Find Node.js on Windows
*/
function findNodeWindows(homeDir: string): NodeFinderResult | null {
// Program Files paths
const programFilesPaths = [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'),
];
for (const nodePath of programFilesPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'program-files' };
}
}
// NVM for Windows
const nvmWindowsPath = path.join(
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
'nvm'
);
const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe');
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm-windows' };
}
// fnm on Windows (prioritize canonical installation path over shell shims)
const fnmWindowsPaths = [
path.join(homeDir, '.fnm', 'node-versions'),
path.join(
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
'fnm',
'node-versions'
),
];
for (const fnmBasePath of fnmWindowsPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe');
if (fnmNode) {
return { nodePath: fnmNode, source: 'fnm' };
}
}
// Scoop installation
const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe');
if (isExecutable(scoopPath)) {
return { nodePath: scoopPath, source: 'scoop' };
}
// Chocolatey installation
const chocoPath = path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'node.exe'
);
if (isExecutable(chocoPath)) {
return { nodePath: chocoPath, source: 'chocolatey' };
}
return null;
}
/**
* Try to find Node.js using shell commands (which/where)
*/
function findNodeViaShell(
platform: NodeJS.Platform,
logger: (message: string) => void = () => {}
): NodeFinderResult | null {
try {
const command = platform === 'win32' ? 'where node' : 'which node';
const result = execSync(command, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
// 'where' on Windows can return multiple lines, take the first
const nodePath = result.split(/\r?\n/)[0];
// Validate path: check for null bytes (security) and executable permission
if (nodePath && !nodePath.includes('\x00') && isExecutable(nodePath)) {
return {
nodePath,
source: platform === 'win32' ? 'where' : 'which',
};
}
} catch {
// Shell command failed (likely when launched from desktop without PATH)
logger('Shell command failed to find Node.js (expected when launched from desktop)');
}
return null;
}
/**
* Find Node.js executable - handles desktop launcher scenarios where PATH is limited
*
* @param options - Configuration options
* @returns Result with path and source information
*
* @example
* ```typescript
* import { findNodeExecutable } from '@automaker/platform';
*
* // In development, skip the search
* const result = findNodeExecutable({ skipSearch: isDev });
* console.log(`Using Node.js from ${result.source}: ${result.nodePath}`);
*
* // Spawn a process with the found Node.js
* spawn(result.nodePath, ['script.js']);
* ```
*/
export function findNodeExecutable(options: NodeFinderOptions = {}): NodeFinderResult {
const { skipSearch = false, logger = () => {} } = options;
// Skip search if requested (e.g., in development mode)
if (skipSearch) {
return { nodePath: 'node', source: 'fallback' };
}
const platform = process.platform;
const homeDir = os.homedir();
// Platform-specific search
let result: NodeFinderResult | null = null;
switch (platform) {
case 'darwin':
result = findNodeMacOS(homeDir);
break;
case 'linux':
result = findNodeLinux(homeDir);
break;
case 'win32':
result = findNodeWindows(homeDir);
break;
}
if (result) {
logger(`Found Node.js via ${result.source} at: ${result.nodePath}`);
return result;
}
// Fallback - try shell resolution (works when launched from terminal)
result = findNodeViaShell(platform, logger);
if (result) {
logger(`Found Node.js via ${result.source} at: ${result.nodePath}`);
return result;
}
// Ultimate fallback
logger('Could not find Node.js, falling back to "node"');
return { nodePath: 'node', source: 'fallback' };
}
/**
* Build an enhanced PATH that includes the Node.js directory
* Useful for ensuring child processes can find Node.js
*
* @param nodePath - Path to the Node.js executable
* @param currentPath - Current PATH environment variable
* @returns Enhanced PATH with Node.js directory prepended if not already present
*
* @example
* ```typescript
* import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
*
* const { nodePath } = findNodeExecutable();
* const enhancedPath = buildEnhancedPath(nodePath, process.env.PATH);
*
* spawn(nodePath, ['script.js'], {
* env: { ...process.env, PATH: enhancedPath }
* });
* ```
*/
export function buildEnhancedPath(nodePath: string, currentPath: string = ''): string {
// If using fallback 'node', don't modify PATH
if (nodePath === 'node') {
return currentPath;
}
const nodeDir = path.dirname(nodePath);
// Don't add if already present or if it's just '.'
// Use path segment matching to avoid false positives (e.g., /opt/node vs /opt/node-v18)
// Normalize paths for comparison to handle mixed separators on Windows
const normalizedNodeDir = path.normalize(nodeDir);
const pathSegments = currentPath.split(path.delimiter).map((s) => path.normalize(s));
if (normalizedNodeDir === '.' || pathSegments.includes(normalizedNodeDir)) {
return currentPath;
}
// Use platform-appropriate path separator
// Handle empty currentPath without adding trailing delimiter
if (!currentPath) {
return nodeDir;
}
return `${nodeDir}${path.delimiter}${currentPath}`;
}

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { findNodeExecutable, buildEnhancedPath } from '../src/node-finder.js';
import path from 'path';
import fs from 'fs';
describe('node-finder', () => {
describe('version sorting and pre-release filtering', () => {
// Test the PRE_RELEASE_PATTERN logic indirectly
const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i;
it('should identify pre-release versions correctly', () => {
const preReleaseVersions = [
'v20.0.0-beta',
'v18.17.0-rc1',
'v19.0.0-alpha',
'v21.0.0-nightly',
'v20.0.0-canary',
'v18.0.0-dev',
'v17.0.0-pre',
];
for (const version of preReleaseVersions) {
expect(PRE_RELEASE_PATTERN.test(version)).toBe(true);
}
});
it('should not match stable versions as pre-release', () => {
const stableVersions = ['v18.17.0', 'v20.10.0', 'v16.20.2', '18.17.0', 'v21.0.0'];
for (const version of stableVersions) {
expect(PRE_RELEASE_PATTERN.test(version)).toBe(false);
}
});
it('should sort versions with numeric comparison', () => {
const versions = ['v18.9.0', 'v18.17.0', 'v20.0.0', 'v8.0.0'];
const sorted = [...versions].sort((a, b) =>
b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })
);
expect(sorted).toEqual(['v20.0.0', 'v18.17.0', 'v18.9.0', 'v8.0.0']);
});
it('should prefer stable over pre-release when filtering', () => {
const allVersions = ['v20.0.0-beta', 'v19.9.9', 'v18.17.0', 'v21.0.0-rc1'];
const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v));
const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v));
const prioritized = [...stableVersions, ...preReleaseVersions];
// Stable versions should come first
expect(prioritized[0]).toBe('v19.9.9');
expect(prioritized[1]).toBe('v18.17.0');
// Pre-release versions should come after
expect(prioritized[2]).toBe('v20.0.0-beta');
expect(prioritized[3]).toBe('v21.0.0-rc1');
});
});
describe('findNodeExecutable', () => {
it("should return 'node' with fallback source when skipSearch is true", () => {
const result = findNodeExecutable({ skipSearch: true });
expect(result.nodePath).toBe('node');
expect(result.source).toBe('fallback');
});
it('should call logger when node is found', () => {
const logger = vi.fn();
findNodeExecutable({ logger });
// Logger should be called at least once (either found or fallback message)
expect(logger).toHaveBeenCalled();
});
it('should return a valid NodeFinderResult structure', () => {
const result = findNodeExecutable();
expect(result).toHaveProperty('nodePath');
expect(result).toHaveProperty('source');
expect(typeof result.nodePath).toBe('string');
expect(result.nodePath.length).toBeGreaterThan(0);
});
it('should find node on the current system', () => {
// This test verifies that node can be found on the test machine
const result = findNodeExecutable();
// Should find node since we're running in Node.js
expect(result.nodePath).toBeDefined();
// Source should be one of the valid sources
const validSources = [
'homebrew',
'system',
'nvm',
'fnm',
'nvm-windows',
'program-files',
'scoop',
'chocolatey',
'which',
'where',
'fallback',
];
expect(validSources).toContain(result.source);
});
it('should find an executable node binary', () => {
const result = findNodeExecutable();
// Skip this test if fallback is used (node not found via path search)
if (result.source === 'fallback') {
expect(result.nodePath).toBe('node');
return;
}
// Verify the found path is actually executable
if (process.platform === 'win32') {
// On Windows, just check file exists (X_OK is not meaningful)
expect(() => fs.accessSync(result.nodePath, fs.constants.F_OK)).not.toThrow();
} else {
// On Unix-like systems, verify execute permission
expect(() => fs.accessSync(result.nodePath, fs.constants.X_OK)).not.toThrow();
}
});
});
describe('buildEnhancedPath', () => {
const delimiter = path.delimiter;
it("should return current path unchanged when nodePath is 'node'", () => {
const currentPath = '/usr/bin:/usr/local/bin';
const result = buildEnhancedPath('node', currentPath);
expect(result).toBe(currentPath);
});
it("should return empty string when nodePath is 'node' and currentPath is empty", () => {
const result = buildEnhancedPath('node', '');
expect(result).toBe('');
});
it('should prepend node directory to path', () => {
const nodePath = '/opt/homebrew/bin/node';
const currentPath = '/usr/bin:/usr/local/bin';
const result = buildEnhancedPath(nodePath, currentPath);
expect(result).toBe(`/opt/homebrew/bin${delimiter}${currentPath}`);
});
it('should not duplicate node directory if already in path', () => {
const nodePath = '/usr/local/bin/node';
const currentPath = '/usr/local/bin:/usr/bin';
const result = buildEnhancedPath(nodePath, currentPath);
expect(result).toBe(currentPath);
});
it('should handle empty currentPath without trailing delimiter', () => {
const nodePath = '/opt/homebrew/bin/node';
const result = buildEnhancedPath(nodePath, '');
expect(result).toBe('/opt/homebrew/bin');
});
it('should handle Windows-style paths', () => {
// On Windows, path.dirname recognizes backslash paths
// On other platforms, backslash is not a path separator
const nodePath = 'C:\\Program Files\\nodejs\\node.exe';
const currentPath = 'C:\\Windows\\System32';
const result = buildEnhancedPath(nodePath, currentPath);
if (process.platform === 'win32') {
// On Windows, should prepend the node directory
expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`);
} else {
// On non-Windows, backslash paths are treated as relative paths
// path.dirname returns '.' so the function returns currentPath unchanged
expect(result).toBe(currentPath);
}
});
it('should use default empty string for currentPath', () => {
const nodePath = '/usr/local/bin/node';
const result = buildEnhancedPath(nodePath);
expect(result).toBe('/usr/local/bin');
});
});
});

View File

@@ -27,7 +27,6 @@ export interface Feature {
titleGenerating?: boolean;
category: string;
description: string;
steps?: string[];
passes?: boolean;
priority?: number;
status?: string;