diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 00000000..1a867179 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -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 diff --git a/README.md b/README.md index eb6e93d2..8d4347fe 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/apps/app/next-env.d.ts b/apps/app/next-env.d.ts deleted file mode 100644 index 20e7bcfb..00000000 --- a/apps/app/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -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. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 26eeb377..98f4561c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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 diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts new file mode 100644 index 00000000..bda4d217 --- /dev/null +++ b/apps/server/src/routes/github/index.ts @@ -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; +} diff --git a/apps/server/src/routes/github/routes/check-github-remote.ts b/apps/server/src/routes/github/routes/check-github-remote.ts new file mode 100644 index 00000000..34a07198 --- /dev/null +++ b/apps/server/src/routes/github/routes/check-github-remote.ts @@ -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 { + 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/common.ts b/apps/server/src/routes/github/routes/common.ts new file mode 100644 index 00000000..790f92c3 --- /dev/null +++ b/apps/server/src/routes/github/routes/common.ts @@ -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); +} diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts new file mode 100644 index 00000000..08f94135 --- /dev/null +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/list-prs.ts b/apps/server/src/routes/github/routes/list-prs.ts new file mode 100644 index 00000000..87f42a38 --- /dev/null +++ b/apps/server/src/routes/github/routes/list-prs.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 42514a0a..c8000ce5 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -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', }, diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 0ab3d54d..1da65e35 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -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 tags like this: @@ -1632,6 +1640,56 @@ When done, wrap your final summary in tags like this: 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 tags like this: + + +## 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] + + +This helps parse your summary correctly in the output logs.`; + } return prompt; } diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index d9d6ee13..e0ab4c4d 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -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 = { diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 093474c0..d209988e 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -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' )} > diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index 5178affa..b22dd8c1 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -51,7 +51,9 @@ export function SidebarNavigation({ return ( - -

- {feature.summary || summary || agentInfo.summary} -

+ {agentInfo.currentPhase && ( +
+ {agentInfo.currentPhase}
)} - {!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && ( -
- - - {agentInfo.toolCallCount} tool calls +
+ + {/* Task List Progress */} + {agentInfo.todos.length > 0 && ( +
+
+ + + {agentInfo.todos.filter((t) => t.status === 'completed').length}/ + {agentInfo.todos.length} tasks - {agentInfo.todos.length > 0 && ( - - - {agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done - +
+
+ {agentInfo.todos.slice(0, 3).map((todo, idx) => ( +
+ {todo.status === 'completed' ? ( + + ) : todo.status === 'in_progress' ? ( + + ) : ( + + )} + + {todo.content} + +
+ ))} + {agentInfo.todos.length > 3 && ( +

+ +{agentInfo.todos.length - 3} more +

)}
- )} - - )} -
+ + )} + + {/* Summary for waiting_approval and verified */} + {(feature.status === 'waiting_approval' || feature.status === 'verified') && ( + <> + {(feature.summary || summary || agentInfo.summary) && ( +
+
+
+ + Summary +
+ +
+

+ {feature.summary || summary || agentInfo.summary} +

+
+ )} + {!feature.summary && + !summary && + !agentInfo.summary && + agentInfo.toolCallCount > 0 && ( +
+ + + {agentInfo.toolCallCount} tool calls + + {agentInfo.todos.length > 0 && ( + + + {agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done + + )} +
+ )} + + )} + + {/* SummaryDialog must be rendered alongside the expand button */} + + ); } diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx index b360ffde..3a49769b 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx @@ -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({ ); })()} - - {/* Steps Preview */} - {showSteps && feature.steps && feature.steps.length > 0 && ( -
- {feature.steps.slice(0, 3).map((step, index) => ( -
- {feature.status === 'verified' ? ( - - ) : ( - - )} - {step} -
- ))} - {feature.steps.length > 3 && ( -

- +{feature.steps.length - 3} more -

- )} -
- )} ); } diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index abeb4e7f..7a610aab 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -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({ {/* Content Sections */} - + {/* Agent Info Panel */} 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({ setNewFeature({ ...newFeature, skipTests })} - steps={newFeature.steps} - onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })} /> diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index d7f9e5ac..bcf68150 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -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({ setEditingFeature({ ...editingFeature, skipTests })} - steps={editingFeature.steps} - onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })} testIdPrefix="edit" /> diff --git a/apps/ui/src/components/views/board-view/dialogs/feature-suggestions-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/feature-suggestions-dialog.tsx index 22067264..3af13bfa 100644 --- a/apps/ui/src/components/views/board-view/dialogs/feature-suggestions-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/feature-suggestions-dialog.tsx @@ -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} - {isExpanded && ( -
- {suggestion.reasoning && ( -

{suggestion.reasoning}

- )} - {suggestion.steps.length > 0 && ( -
-

- Implementation Steps: -

-
    - {suggestion.steps.map((step, i) => ( -
  • {step}
  • - ))} -
-
- )} + {isExpanded && suggestion.reasoning && ( +
+

{suggestion.reasoning}

)}
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index e58079ac..97bb47a6 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -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; diff --git a/apps/ui/src/components/views/board-view/shared/testing-tab-content.tsx b/apps/ui/src/components/views/board-view/shared/testing-tab-content.tsx index 68be2107..1e6139f4 100644 --- a/apps/ui/src/components/views/board-view/shared/testing-tab-content.tsx +++ b/apps/ui/src/components/views/board-view/shared/testing-tab-content.tsx @@ -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 (
@@ -48,37 +32,9 @@ export function TestingTabContent({

- 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.

- - {/* Verification Steps - Only shown when skipTests is enabled */} - {skipTests && ( -
- -

- Add manual steps to verify this feature works correctly. -

- {steps.map((step, index) => ( - handleStepChange(index, e.target.value)} - data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}feature-step-${index}${testIdPrefix ? '' : '-input'}`} - /> - ))} - -
- )} ); } diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx new file mode 100644 index 00000000..3d254cb2 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -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([]); + const [closedIssues, setClosedIssues] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [selectedIssue, setSelectedIssue] = useState(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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ +
+

Failed to Load Issues

+

{error}

+ +
+ ); + } + + const totalIssues = openIssues.length + closedIssues.length; + + return ( +
+ {/* Issues List */} +
+ {/* Header */} +
+
+
+ +
+
+

Issues

+

+ {totalIssues === 0 + ? 'No issues found' + : `${openIssues.length} open, ${closedIssues.length} closed`} +

+
+
+ +
+ + {/* Issues List */} +
+ {totalIssues === 0 ? ( +
+
+ +
+

No Issues

+

This repository has no issues yet.

+
+ ) : ( +
+ {/* Open Issues */} + {openIssues.map((issue) => ( + setSelectedIssue(issue)} + onOpenExternal={() => handleOpenInGitHub(issue.url)} + formatDate={formatDate} + /> + ))} + + {/* Closed Issues Section */} + {closedIssues.length > 0 && ( + <> +
+ Closed Issues ({closedIssues.length}) +
+ {closedIssues.map((issue) => ( + setSelectedIssue(issue)} + onOpenExternal={() => handleOpenInGitHub(issue.url)} + formatDate={formatDate} + /> + ))} + + )} +
+ )} +
+
+ + {/* Issue Detail Panel */} + {selectedIssue && ( +
+ {/* Detail Header */} +
+
+ {selectedIssue.state === 'OPEN' ? ( + + ) : ( + + )} + + #{selectedIssue.number} {selectedIssue.title} + +
+
+ + +
+
+ + {/* Issue Detail Content */} +
+ {/* Title */} +

{selectedIssue.title}

+ + {/* Meta info */} +
+ + {selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'} + + + #{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '} + {selectedIssue.author.login} + +
+ + {/* Labels */} + {selectedIssue.labels.length > 0 && ( +
+ {selectedIssue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Body */} + {selectedIssue.body ? ( +
+
{selectedIssue.body}
+
+ ) : ( +

No description provided.

+ )} + + {/* Open in GitHub CTA */} +
+

+ View comments, add reactions, and more on GitHub. +

+ +
+
+
+ )} +
+ ); +} + +interface IssueRowProps { + issue: GitHubIssue; + isSelected: boolean; + onClick: () => void; + onOpenExternal: () => void; + formatDate: (date: string) => string; +} + +function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) { + return ( +
+ {issue.state === 'OPEN' ? ( + + ) : ( + + )} + +
+
+ {issue.title} +
+ +
+ + #{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login} + +
+ + {issue.labels.length > 0 && ( +
+ {issue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} +
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx new file mode 100644 index 00000000..4c613653 --- /dev/null +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -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([]); + const [mergedPRs, setMergedPRs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [selectedPR, setSelectedPR] = useState(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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ +
+

Failed to Load Pull Requests

+

{error}

+ +
+ ); + } + + const totalPRs = openPRs.length + mergedPRs.length; + + return ( +
+ {/* PR List */} +
+ {/* Header */} +
+
+
+ +
+
+

Pull Requests

+

+ {totalPRs === 0 + ? 'No pull requests found' + : `${openPRs.length} open, ${mergedPRs.length} merged`} +

+
+
+ +
+ + {/* PR List */} +
+ {totalPRs === 0 ? ( +
+
+ +
+

No Pull Requests

+

+ This repository has no pull requests yet. +

+
+ ) : ( +
+ {/* Open PRs */} + {openPRs.map((pr) => ( + setSelectedPR(pr)} + onOpenExternal={() => handleOpenInGitHub(pr.url)} + formatDate={formatDate} + getReviewStatus={getReviewStatus} + /> + ))} + + {/* Merged PRs Section */} + {mergedPRs.length > 0 && ( + <> +
+ Merged ({mergedPRs.length}) +
+ {mergedPRs.map((pr) => ( + setSelectedPR(pr)} + onOpenExternal={() => handleOpenInGitHub(pr.url)} + formatDate={formatDate} + getReviewStatus={getReviewStatus} + /> + ))} + + )} +
+ )} +
+
+ + {/* PR Detail Panel */} + {selectedPR && ( +
+ {/* Detail Header */} +
+
+ {selectedPR.state === 'MERGED' ? ( + + ) : ( + + )} + + #{selectedPR.number} {selectedPR.title} + + {selectedPR.isDraft && ( + + Draft + + )} +
+
+ + +
+
+ + {/* PR Detail Content */} +
+ {/* Title */} +

{selectedPR.title}

+ + {/* Meta info */} +
+ + {selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'} + + {getReviewStatus(selectedPR) && ( + + {getReviewStatus(selectedPR)!.label} + + )} + + #{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '} + {selectedPR.author.login} + +
+ + {/* Branch info */} + {selectedPR.headRefName && ( +
+ Branch: + + {selectedPR.headRefName} + +
+ )} + + {/* Labels */} + {selectedPR.labels.length > 0 && ( +
+ {selectedPR.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Body */} + {selectedPR.body ? ( +
+
{selectedPR.body}
+
+ ) : ( +

No description provided.

+ )} + + {/* Open in GitHub CTA */} +
+

+ View code changes, comments, and reviews on GitHub. +

+ +
+
+
+ )} +
+ ); +} + +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 ( +
+ {pr.state === 'MERGED' ? ( + + ) : ( + + )} + +
+
+ {pr.title} + {pr.isDraft && ( + + Draft + + )} +
+ +
+ + #{pr.number} opened {formatDate(pr.createdAt)} by {pr.author.login} + + {pr.headRefName && ( + + {pr.headRefName} + + )} +
+ +
+ {/* Review Status */} + {reviewStatus && ( + + {reviewStatus.label} + + )} + + {/* Labels */} + {pr.labels.map((label) => ( + + {label.name} + + ))} +
+
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/interview-view.tsx b/apps/ui/src/components/views/interview-view.tsx index 47127e10..71d0fa4d 100644 --- a/apps/ui/src/components/views/interview-view.tsx +++ b/apps/ui/src/components/views/interview-view.tsx @@ -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, }; diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 91d30091..f5b3e922 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -92,12 +92,77 @@ export interface RunningAgentsAPI { getAll: () => Promise; } +// 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 { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index c4ca56ad..00b96d6b 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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<{ diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 6d65e3e7..82ad7452 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -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)); diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index d6d3d2a3..5c3fb52f 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -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 { * Start the backend server */ async function startServer(): Promise { - 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 { 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 { ? 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(); } diff --git a/apps/ui/src/routes/github-issues.tsx b/apps/ui/src/routes/github-issues.tsx new file mode 100644 index 00000000..16506e47 --- /dev/null +++ b/apps/ui/src/routes/github-issues.tsx @@ -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, +}); diff --git a/apps/ui/src/routes/github-prs.tsx b/apps/ui/src/routes/github-prs.tsx new file mode 100644 index 00000000..78e321a6 --- /dev/null +++ b/apps/ui/src/routes/github-prs.tsx @@ -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, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index da1c3ac3..80ad7019 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -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 diff --git a/apps/ui/tests/e2e-testing-guide.md b/apps/ui/tests/e2e-testing-guide.md new file mode 100644 index 00000000..8b07dd53 --- /dev/null +++ b/apps/ui/tests/e2e-testing-guide.md @@ -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. diff --git a/apps/ui/tests/open-project.spec.ts b/apps/ui/tests/open-project.spec.ts new file mode 100644 index 00000000..87885604 --- /dev/null +++ b/apps/ui/tests/open-project.spec.ts @@ -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); + }); +}); diff --git a/apps/ui/tests/project-creation.spec.ts b/apps/ui/tests/project-creation.spec.ts new file mode 100644 index 00000000..9d71d4ff --- /dev/null +++ b/apps/ui/tests/project-creation.spec.ts @@ -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); + }); +}); diff --git a/apps/ui/tests/utils/project/setup.ts b/apps/ui/tests/utils/project/setup.ts index ee24b376..23f87d8d 100644 --- a/apps/ui/tests/utils/project/setup.ts +++ b/apps/ui/tests/utils/project/setup.ts @@ -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 { + 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 { + 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 { * Set up the app to skip the setup wizard (setup already complete) */ export async function setupComplete(page: Page): Promise { - 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 { 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); } /** diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 0794e109..ae4cc0d8 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -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'; diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts new file mode 100644 index 00000000..ed2cbb03 --- /dev/null +++ b/libs/platform/src/node-finder.ts @@ -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}`; +} diff --git a/libs/platform/tests/node-finder.test.ts b/libs/platform/tests/node-finder.test.ts new file mode 100644 index 00000000..6956884b --- /dev/null +++ b/libs/platform/tests/node-finder.test.ts @@ -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'); + }); + }); +}); diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index c81df7c3..593b626b 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -27,7 +27,6 @@ export interface Feature { titleGenerating?: boolean; category: string; description: string; - steps?: string[]; passes?: boolean; priority?: number; status?: string;