mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch 'AutoMaker-Org:main' into claude/task-dependency-graph-iPz1k
This commit is contained in:
30
.github/workflows/security-audit.yml
vendored
Normal file
30
.github/workflows/security-audit.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Security Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
schedule:
|
||||
# Run weekly on Mondays at 9 AM UTC
|
||||
- cron: '0 9 * * 1'
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Run npm audit
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: false
|
||||
@@ -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
|
||||
```
|
||||
|
||||
6
apps/app/next-env.d.ts
vendored
6
apps/app/next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/dev/types/routes.d.ts';
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -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
|
||||
|
||||
18
apps/server/src/routes/github/index.ts
Normal file
18
apps/server/src/routes/github/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* GitHub routes - HTTP API for GitHub integration
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
|
||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||
import { createListPRsHandler } from './routes/list-prs.js';
|
||||
|
||||
export function createGitHubRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/check-remote', createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', createListIssuesHandler());
|
||||
router.post('/prs', createListPRsHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
71
apps/server/src/routes/github/routes/check-github-remote.ts
Normal file
71
apps/server/src/routes/github/routes/check-github-remote.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* GET /check-github-remote endpoint - Check if project has a GitHub remote
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
|
||||
export interface GitHubRemoteStatus {
|
||||
hasGitHubRemote: boolean;
|
||||
remoteUrl: string | null;
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
}
|
||||
|
||||
export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemoteStatus> {
|
||||
const status: GitHubRemoteStatus = {
|
||||
hasGitHubRemote: false,
|
||||
remoteUrl: null,
|
||||
owner: null,
|
||||
repo: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Get the remote URL (origin by default)
|
||||
const { stdout } = await execAsync('git remote get-url origin', {
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
const remoteUrl = stdout.trim();
|
||||
status.remoteUrl = remoteUrl;
|
||||
|
||||
// Check if it's a GitHub URL
|
||||
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
|
||||
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/);
|
||||
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/);
|
||||
|
||||
const match = httpsMatch || sshMatch;
|
||||
if (match) {
|
||||
status.hasGitHubRemote = true;
|
||||
status.owner = match[1];
|
||||
status.repo = match[2].replace(/\.git$/, '');
|
||||
}
|
||||
} catch {
|
||||
// No remote or not a git repo - that's okay
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export function createCheckGitHubRemoteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await checkGitHubRemote(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Check GitHub remote failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
35
apps/server/src/routes/github/routes/common.ts
Normal file
35
apps/server/src/routes/github/routes/common.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Common utilities for GitHub routes
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
export const extendedPath = [
|
||||
process.env.PATH,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context: string): void {
|
||||
console.error(`[GitHub] ${context}:`, error);
|
||||
}
|
||||
90
apps/server/src/routes/github/routes/list-issues.ts
Normal file
90
apps/server/src/routes/github/routes/list-issues.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* POST /list-issues endpoint - List GitHub issues for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
|
||||
export interface GitHubLabel {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GitHubAuthor {
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface GitHubIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ListIssuesResult {
|
||||
success: boolean;
|
||||
openIssues?: GitHubIssue[];
|
||||
closedIssues?: GitHubIssue[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function createListIssuesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// First check if this is a GitHub repo
|
||||
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||
if (!remoteStatus.hasGitHubRemote) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Project does not have a GitHub remote',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch open and closed issues in parallel
|
||||
const [openResult, closedResult] = await Promise.all([
|
||||
execAsync(
|
||||
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
}
|
||||
),
|
||||
execAsync(
|
||||
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
const { stdout: openStdout } = openResult;
|
||||
const { stdout: closedStdout } = closedResult;
|
||||
|
||||
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
||||
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
openIssues,
|
||||
closedIssues,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List GitHub issues failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
92
apps/server/src/routes/github/routes/list-prs.ts
Normal file
92
apps/server/src/routes/github/routes/list-prs.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* POST /list-prs endpoint - List GitHub pull requests for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
|
||||
export interface GitHubLabel {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GitHubAuthor {
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface GitHubPR {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
isDraft: boolean;
|
||||
headRefName: string;
|
||||
reviewDecision: string | null;
|
||||
mergeable: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ListPRsResult {
|
||||
success: boolean;
|
||||
openPRs?: GitHubPR[];
|
||||
mergedPRs?: GitHubPR[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function createListPRsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// First check if this is a GitHub repo
|
||||
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||
if (!remoteStatus.hasGitHubRemote) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Project does not have a GitHub remote',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const [openResult, mergedResult] = await Promise.all([
|
||||
execAsync(
|
||||
'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
}
|
||||
),
|
||||
execAsync(
|
||||
'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
}
|
||||
),
|
||||
]);
|
||||
const { stdout: openStdout } = openResult;
|
||||
const { stdout: mergedStdout } = mergedResult;
|
||||
|
||||
const openPRs: GitHubPR[] = JSON.parse(openStdout || '[]');
|
||||
const mergedPRs: GitHubPR[] = JSON.parse(mergedStdout || '[]');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
openPRs,
|
||||
mergedPRs,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List GitHub PRs failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -607,15 +607,18 @@ export class AutoModeService {
|
||||
}
|
||||
);
|
||||
|
||||
// Mark as waiting_approval for user review
|
||||
await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval');
|
||||
// Determine final status based on testing mode:
|
||||
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
|
||||
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Feature completed in ${Math.round(
|
||||
(Date.now() - tempRunningFeature.startTime) / 1000
|
||||
)}s`,
|
||||
)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -879,13 +882,16 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
}
|
||||
);
|
||||
|
||||
// Mark as waiting_approval for user review
|
||||
await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval');
|
||||
// Determine final status based on testing mode:
|
||||
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
|
||||
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
|
||||
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: 'Follow-up completed successfully',
|
||||
message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1606,15 +1612,17 @@ You can use the Read tool to view these images at any time during implementation
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
// Add verification instructions based on testing mode
|
||||
if (feature.skipTests) {
|
||||
// Manual verification - just implement the feature
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
Implement this feature by:
|
||||
1. First, explore the codebase to understand the existing structure
|
||||
2. Plan your implementation approach
|
||||
3. Write the necessary code changes
|
||||
4. Add or update tests as needed
|
||||
5. Ensure the code follows existing patterns and conventions
|
||||
4. Ensure the code follows existing patterns and conventions
|
||||
|
||||
When done, wrap your final summary in <summary> tags like this:
|
||||
|
||||
@@ -1632,6 +1640,56 @@ When done, wrap your final summary in <summary> tags like this:
|
||||
</summary>
|
||||
|
||||
This helps parse your summary correctly in the output logs.`;
|
||||
} else {
|
||||
// Automated testing - implement and verify with Playwright
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
Implement this feature by:
|
||||
1. First, explore the codebase to understand the existing structure
|
||||
2. Plan your implementation approach
|
||||
3. Write the necessary code changes
|
||||
4. Ensure the code follows existing patterns and conventions
|
||||
|
||||
## Verification with Playwright (REQUIRED)
|
||||
|
||||
After implementing the feature, you MUST verify it works correctly using Playwright:
|
||||
|
||||
1. **Create a temporary Playwright test** to verify the feature works as expected
|
||||
2. **Run the test** to confirm the feature is working
|
||||
3. **Delete the test file** after verification - this is a temporary verification test, not a permanent test suite addition
|
||||
|
||||
Example verification workflow:
|
||||
\`\`\`bash
|
||||
# Create a simple verification test
|
||||
npx playwright test my-verification-test.spec.ts
|
||||
|
||||
# After successful verification, delete the test
|
||||
rm my-verification-test.spec.ts
|
||||
\`\`\`
|
||||
|
||||
The test should verify the core functionality of the feature. If the test fails, fix the implementation and re-test.
|
||||
|
||||
When done, wrap your final summary in <summary> tags like this:
|
||||
|
||||
<summary>
|
||||
## Summary: [Feature Title]
|
||||
|
||||
### Changes Implemented
|
||||
- [List of changes made]
|
||||
|
||||
### Files Modified
|
||||
- [List of files]
|
||||
|
||||
### Verification Status
|
||||
- [Describe how the feature was verified with Playwright]
|
||||
|
||||
### Notes for Developer
|
||||
- [Any important notes]
|
||||
</summary>
|
||||
|
||||
This helps parse your summary correctly in the output logs.`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { AutomakerLogo } from './automaker-logo';
|
||||
import { BugReportButton } from './bug-report-button';
|
||||
|
||||
@@ -20,7 +20,9 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
|
||||
// Background gradient for depth
|
||||
'bg-gradient-to-b from-transparent to-background/5',
|
||||
'flex items-center',
|
||||
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center'
|
||||
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center',
|
||||
// Add left padding on macOS to avoid overlapping with traffic light buttons
|
||||
isMac && 'pt-4 pl-20'
|
||||
)}
|
||||
>
|
||||
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
|
||||
|
||||
@@ -51,7 +51,9 @@ export function SidebarNavigation({
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigate({ to: `/${item.id}` as const })}
|
||||
onClick={() => {
|
||||
navigate({ to: `/${item.id}` as const });
|
||||
}}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
||||
'transition-all duration-200 ease-out',
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react';
|
||||
import {
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Bot,
|
||||
BookOpen,
|
||||
UserCircle,
|
||||
Terminal,
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
interface UseNavigationProps {
|
||||
shortcuts: {
|
||||
@@ -51,6 +62,30 @@ export function useNavigation({
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
}: UseNavigationProps) {
|
||||
// Track if current project has a GitHub remote
|
||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkGitHubRemote() {
|
||||
if (!currentProject?.path) {
|
||||
setHasGitHubRemote(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github) {
|
||||
const result = await api.github.checkRemote(currentProject.path);
|
||||
setHasGitHubRemote(result.success && result.hasGitHubRemote === true);
|
||||
}
|
||||
} catch {
|
||||
setHasGitHubRemote(false);
|
||||
}
|
||||
}
|
||||
|
||||
checkGitHubRemote();
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Build navigation sections
|
||||
const navSections: NavSection[] = useMemo(() => {
|
||||
const allToolsItems: NavItem[] = [
|
||||
@@ -114,7 +149,7 @@ export function useNavigation({
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
label: 'Project',
|
||||
items: projectItems,
|
||||
@@ -124,7 +159,28 @@ export function useNavigation({
|
||||
items: visibleToolsItems,
|
||||
},
|
||||
];
|
||||
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
|
||||
|
||||
// Add GitHub section if project has a GitHub remote
|
||||
if (hasGitHubRemote) {
|
||||
sections.push({
|
||||
label: 'GitHub',
|
||||
items: [
|
||||
{
|
||||
id: 'github-issues',
|
||||
label: 'Issues',
|
||||
icon: CircleDot,
|
||||
},
|
||||
{
|
||||
id: 'github-prs',
|
||||
label: 'Pull Requests',
|
||||
icon: GitPullRequest,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useProjectCreation({
|
||||
// Write initial app_spec.txt with proper XML structure
|
||||
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
|
||||
const api = getElectronAPI();
|
||||
await api.fs.writeFile(
|
||||
await api.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
@@ -103,7 +103,7 @@ export function useProjectCreation({
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Create project directory
|
||||
await api.fs.createFolder(projectPath);
|
||||
await api.mkdir(projectPath);
|
||||
|
||||
// Finalize project setup
|
||||
await finalizeProjectCreation(projectPath, projectName);
|
||||
@@ -127,16 +127,19 @@ export function useProjectCreation({
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Clone template repository
|
||||
await api.git.clone(template.githubUrl, projectPath);
|
||||
const cloneResult = await api.templates.clone(template.repoUrl, projectName, parentDir);
|
||||
if (!cloneResult.success) {
|
||||
throw new Error(cloneResult.error || 'Failed to clone template');
|
||||
}
|
||||
const projectPath = cloneResult.projectPath!;
|
||||
|
||||
// Initialize .automaker directory structure
|
||||
await initializeProject(projectPath);
|
||||
|
||||
// Write app_spec.txt with template-specific info
|
||||
await api.fs.writeFile(
|
||||
await api.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
@@ -196,16 +199,19 @@ export function useProjectCreation({
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Clone custom repository
|
||||
await api.git.clone(repoUrl, projectPath);
|
||||
const cloneResult = await api.templates.clone(repoUrl, projectName, parentDir);
|
||||
if (!cloneResult.success) {
|
||||
throw new Error(cloneResult.error || 'Failed to clone repository');
|
||||
}
|
||||
const projectPath = cloneResult.projectPath!;
|
||||
|
||||
// Initialize .automaker directory structure
|
||||
await initializeProject(projectPath);
|
||||
|
||||
// Write app_spec.txt with custom URL info
|
||||
await api.fs.writeFile(
|
||||
await api.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
@@ -426,7 +426,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
interface DetectedFeature {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
passes: boolean;
|
||||
}
|
||||
|
||||
@@ -453,11 +452,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Testing',
|
||||
description: 'Automated test suite',
|
||||
steps: [
|
||||
'Step 1: Tests directory exists',
|
||||
'Step 2: Test files are present',
|
||||
'Step 3: Run test suite',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -471,11 +465,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'UI/Design',
|
||||
description: 'Component-based UI architecture',
|
||||
steps: [
|
||||
'Step 1: Components directory exists',
|
||||
'Step 2: UI components are defined',
|
||||
'Step 3: Components are reusable',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -485,11 +474,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Project Structure',
|
||||
description: 'Organized source code structure',
|
||||
steps: [
|
||||
'Step 1: Source directory exists',
|
||||
'Step 2: Code is properly organized',
|
||||
'Step 3: Follows best practices',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -504,11 +488,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Frontend',
|
||||
description: 'React-based user interface',
|
||||
steps: [
|
||||
'Step 1: React is installed',
|
||||
'Step 2: Components render correctly',
|
||||
'Step 3: State management works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -517,11 +496,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Framework',
|
||||
description: 'Next.js framework integration',
|
||||
steps: [
|
||||
'Step 1: Next.js is configured',
|
||||
'Step 2: Pages/routes are defined',
|
||||
'Step 3: Server-side rendering works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -536,11 +510,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Developer Experience',
|
||||
description: 'TypeScript type safety',
|
||||
steps: [
|
||||
'Step 1: TypeScript is configured',
|
||||
'Step 2: Type definitions exist',
|
||||
'Step 3: Code compiles without errors',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -550,11 +519,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'UI/Design',
|
||||
description: 'Tailwind CSS styling',
|
||||
steps: [
|
||||
'Step 1: Tailwind is configured',
|
||||
'Step 2: Styles are applied',
|
||||
'Step 3: Responsive design works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -564,11 +528,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Developer Experience',
|
||||
description: 'Code quality tools',
|
||||
steps: [
|
||||
'Step 1: Linter is configured',
|
||||
'Step 2: Code passes lint checks',
|
||||
'Step 3: Formatting is consistent',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -578,11 +537,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Platform',
|
||||
description: 'Electron desktop application',
|
||||
steps: [
|
||||
'Step 1: Electron is configured',
|
||||
'Step 2: Main process runs',
|
||||
'Step 3: Renderer process loads',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -592,11 +546,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Testing',
|
||||
description: 'Playwright end-to-end testing',
|
||||
steps: [
|
||||
'Step 1: Playwright is configured',
|
||||
'Step 2: E2E tests are defined',
|
||||
'Step 3: Tests pass successfully',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -610,11 +559,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Documentation',
|
||||
description: 'Project documentation',
|
||||
steps: [
|
||||
'Step 1: README exists',
|
||||
'Step 2: Documentation is comprehensive',
|
||||
'Step 3: Setup instructions are clear',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -629,11 +573,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'DevOps',
|
||||
description: 'CI/CD pipeline configuration',
|
||||
steps: [
|
||||
'Step 1: CI config exists',
|
||||
'Step 2: Pipeline runs on push',
|
||||
'Step 3: Automated checks pass',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -647,11 +586,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Backend',
|
||||
description: 'API endpoints',
|
||||
steps: [
|
||||
'Step 1: API routes are defined',
|
||||
'Step 2: Endpoints respond correctly',
|
||||
'Step 3: Error handling is implemented',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -669,11 +603,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Architecture',
|
||||
description: 'State management system',
|
||||
steps: [
|
||||
'Step 1: Store is configured',
|
||||
'Step 2: State updates correctly',
|
||||
'Step 3: Components access state',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -683,11 +612,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Configuration',
|
||||
description: 'Project configuration files',
|
||||
steps: [
|
||||
'Step 1: Config files exist',
|
||||
'Step 2: Configuration is valid',
|
||||
'Step 3: Build process works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -700,11 +624,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
detectedFeatures.push({
|
||||
category: 'Core',
|
||||
description: 'Basic project structure',
|
||||
steps: [
|
||||
'Step 1: Project directory exists',
|
||||
'Step 2: Files are present',
|
||||
'Step 3: Project can be loaded',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
@@ -719,7 +638,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
id: crypto.randomUUID(),
|
||||
category: detectedFeature.category,
|
||||
description: detectedFeature.description,
|
||||
steps: detectedFeature.steps,
|
||||
status: 'backlog',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -436,9 +436,9 @@ export function BoardView() {
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Address PR #${prNumber} Review Comments`,
|
||||
category: 'PR Review',
|
||||
description,
|
||||
steps: [],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
@@ -478,9 +478,9 @@ export function BoardView() {
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Resolve Merge Conflicts`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
steps: [],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
|
||||
@@ -128,116 +128,130 @@ export function AgentInfoPanel({
|
||||
// Agent Info Panel for non-backlog cards
|
||||
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
|
||||
return (
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
{/* Model & Phase */}
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
{agentInfo.currentPhase && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
|
||||
agentInfo.currentPhase === 'planning' &&
|
||||
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
|
||||
agentInfo.currentPhase === 'action' &&
|
||||
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
|
||||
agentInfo.currentPhase === 'verification' &&
|
||||
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
|
||||
)}
|
||||
>
|
||||
{agentInfo.currentPhase}
|
||||
<>
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
{/* Model & Phase */}
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task List Progress */}
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<ListTodo className="w-3 h-3" />
|
||||
<span>
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
|
||||
{agentInfo.todos.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{agentInfo.todos.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||
+{agentInfo.todos.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary for waiting_approval and verified */}
|
||||
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
{(feature.summary || summary || agentInfo.summary) && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
||||
<Sparkles className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate font-medium">Summary</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
|
||||
title="View full summary"
|
||||
data-testid={`expand-summary-${feature.id}`}
|
||||
>
|
||||
<Expand className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
</p>
|
||||
{agentInfo.currentPhase && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
|
||||
agentInfo.currentPhase === 'planning' &&
|
||||
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
|
||||
agentInfo.currentPhase === 'action' &&
|
||||
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
|
||||
agentInfo.currentPhase === 'verification' &&
|
||||
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
|
||||
)}
|
||||
>
|
||||
{agentInfo.currentPhase}
|
||||
</div>
|
||||
)}
|
||||
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wrench className="w-2.5 h-2.5" />
|
||||
{agentInfo.toolCallCount} tool calls
|
||||
</div>
|
||||
|
||||
{/* Task List Progress */}
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<ListTodo className="w-3 h-3" />
|
||||
<span>
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
|
||||
{agentInfo.todos.length} tasks
|
||||
</span>
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{agentInfo.todos.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||
+{agentInfo.todos.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary for waiting_approval and verified */}
|
||||
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
{(feature.summary || summary || agentInfo.summary) && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
||||
<Sparkles className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate font-medium">Summary</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
|
||||
title="View full summary"
|
||||
data-testid={`expand-summary-${feature.id}`}
|
||||
>
|
||||
<Expand className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!feature.summary &&
|
||||
!summary &&
|
||||
!agentInfo.summary &&
|
||||
agentInfo.toolCallCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wrench className="w-2.5 h-2.5" />
|
||||
{agentInfo.toolCallCount} tool calls
|
||||
</span>
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* SummaryDialog must be rendered alongside the expand button */}
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
|
||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface CardContentSectionsProps {
|
||||
feature: Feature;
|
||||
useWorktrees: boolean;
|
||||
showSteps: boolean;
|
||||
}
|
||||
|
||||
export function CardContentSections({
|
||||
feature,
|
||||
useWorktrees,
|
||||
showSteps,
|
||||
}: CardContentSectionsProps) {
|
||||
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Target Branch Display */}
|
||||
@@ -50,30 +45,6 @@ export function CardContentSections({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Steps Preview */}
|
||||
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{feature.steps.slice(0, 3).map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
|
||||
>
|
||||
{feature.status === 'verified' ? (
|
||||
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.steps.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-5">
|
||||
+{feature.steps.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,9 +61,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
}: KanbanCardProps) {
|
||||
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
|
||||
|
||||
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
|
||||
const { useWorktrees } = useAppStore();
|
||||
|
||||
const isDraggable =
|
||||
feature.status === 'backlog' ||
|
||||
@@ -152,7 +150,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
{/* Content Sections */}
|
||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
|
||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
||||
|
||||
{/* Agent Info Panel */}
|
||||
<AgentInfoPanel
|
||||
|
||||
@@ -62,7 +62,6 @@ interface AddFeatureDialogProps {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
images: FeatureImage[];
|
||||
imagePaths: DescriptionImagePath[];
|
||||
textFilePaths: DescriptionTextFilePath[];
|
||||
@@ -105,7 +104,6 @@ export function AddFeatureDialog({
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
steps: [''],
|
||||
images: [] as FeatureImage[],
|
||||
imagePaths: [] as DescriptionImagePath[],
|
||||
textFilePaths: [] as DescriptionTextFilePath[],
|
||||
@@ -193,7 +191,6 @@ export function AddFeatureDialog({
|
||||
title: newFeature.title,
|
||||
category,
|
||||
description: newFeature.description,
|
||||
steps: newFeature.steps.filter((s) => s.trim()),
|
||||
images: newFeature.images,
|
||||
imagePaths: newFeature.imagePaths,
|
||||
textFilePaths: newFeature.textFilePaths,
|
||||
@@ -211,7 +208,6 @@ export function AddFeatureDialog({
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
steps: [''],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
textFilePaths: [],
|
||||
@@ -502,8 +498,6 @@ export function AddFeatureDialog({
|
||||
<TestingTabContent
|
||||
skipTests={newFeature.skipTests}
|
||||
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
|
||||
steps={newFeature.steps}
|
||||
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -64,7 +64,6 @@ interface EditFeatureDialogProps {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
@@ -165,7 +164,6 @@ export function EditFeatureDialog({
|
||||
title: editingFeature.title ?? '',
|
||||
category: editingFeature.category,
|
||||
description: editingFeature.description,
|
||||
steps: editingFeature.steps,
|
||||
skipTests: editingFeature.skipTests ?? false,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
@@ -491,8 +489,6 @@ export function EditFeatureDialog({
|
||||
<TestingTabContent
|
||||
skipTests={editingFeature.skipTests ?? false}
|
||||
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
|
||||
steps={editingFeature.steps}
|
||||
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
|
||||
testIdPrefix="edit"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -245,7 +245,6 @@ export function FeatureSuggestionsDialog({
|
||||
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
category: s.category,
|
||||
description: s.description,
|
||||
steps: s.steps,
|
||||
status: 'backlog' as const,
|
||||
skipTests: true, // As specified, testing mode true
|
||||
priority: s.priority, // Preserve priority from suggestion
|
||||
@@ -453,23 +452,9 @@ export function FeatureSuggestionsDialog({
|
||||
{suggestion.description}
|
||||
</Label>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{suggestion.reasoning && (
|
||||
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
|
||||
)}
|
||||
{suggestion.steps.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Implementation Steps:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
|
||||
{suggestion.steps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && suggestion.reasoning && (
|
||||
<div className="mt-3 text-sm">
|
||||
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { FlaskConical, Plus } from 'lucide-react';
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
|
||||
interface TestingTabContentProps {
|
||||
skipTests: boolean;
|
||||
onSkipTestsChange: (skipTests: boolean) => void;
|
||||
steps: string[];
|
||||
onStepsChange: (steps: string[]) => void;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function TestingTabContent({
|
||||
skipTests,
|
||||
onSkipTestsChange,
|
||||
steps,
|
||||
onStepsChange,
|
||||
testIdPrefix = '',
|
||||
}: TestingTabContentProps) {
|
||||
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : 'skip-tests';
|
||||
|
||||
const handleStepChange = (index: number, value: string) => {
|
||||
const newSteps = [...steps];
|
||||
newSteps[index] = value;
|
||||
onStepsChange(newSteps);
|
||||
};
|
||||
|
||||
const handleAddStep = () => {
|
||||
onStepsChange([...steps, '']);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -48,37 +32,9 @@ export function TestingTabContent({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, this feature will use automated TDD. When disabled, it will require manual
|
||||
verification.
|
||||
When enabled, the agent will use Playwright to verify the feature works correctly before
|
||||
marking it as verified. When disabled, manual verification will be required.
|
||||
</p>
|
||||
|
||||
{/* Verification Steps - Only shown when skipTests is enabled */}
|
||||
{skipTests && (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label>Verification Steps</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Add manual steps to verify this feature works correctly.
|
||||
</p>
|
||||
{steps.map((step, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
value={step}
|
||||
placeholder={`Verification step ${index + 1}`}
|
||||
onChange={(e) => handleStepChange(index, e.target.value)}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}feature-step-${index}${testIdPrefix ? '' : '-input'}`}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddStep}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}add-step-button`}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Verification Step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
334
apps/ui/src/components/views/github-issues-view.tsx
Normal file
334
apps/ui/src/components/views/github-issues-view.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function GitHubIssuesView() {
|
||||
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
|
||||
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
const fetchIssues = useCallback(async () => {
|
||||
if (!currentProject?.path) {
|
||||
setError('No project selected');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const api = getElectronAPI();
|
||||
if (api.github) {
|
||||
const result = await api.github.listIssues(currentProject.path);
|
||||
if (result.success) {
|
||||
setOpenIssues(result.openIssues || []);
|
||||
setClosedIssues(result.closedIssues || []);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch issues');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GitHubIssuesView] Error fetching issues:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
|
||||
const handleOpenInGitHub = useCallback((url: string) => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink(url);
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-destructive/10 mb-4">
|
||||
<CircleDot className="h-12 w-12 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">Failed to Load Issues</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalIssues = openIssues.length + closedIssues.length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Issues List */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden border-r border-border',
|
||||
selectedIssue ? 'w-80' : 'flex-1'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalIssues === 0
|
||||
? 'No issues found'
|
||||
: `${openIssues.length} open, ${closedIssues.length} closed`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Issues List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{totalIssues === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium mb-2">No Issues</h2>
|
||||
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{/* Open Issues */}
|
||||
{openIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
isSelected={selectedIssue?.number === issue.number}
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Closed Issues Section */}
|
||||
{closedIssues.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||
Closed Issues ({closedIssues.length})
|
||||
</div>
|
||||
{closedIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
isSelected={selectedIssue?.number === issue.number}
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Detail Panel */}
|
||||
{selectedIssue && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedIssue.state === 'OPEN' ? (
|
||||
<Circle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
#{selectedIssue.number} {selectedIssue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenInGitHub(selectedIssue.url)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Open in GitHub
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIssue(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Detail Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold mb-2">{selectedIssue.title}</h1>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
selectedIssue.state === 'OPEN'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-purple-500/10 text-purple-500'
|
||||
)}
|
||||
>
|
||||
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
<span>
|
||||
#{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '}
|
||||
<span className="font-medium text-foreground">{selectedIssue.author.login}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{selectedIssue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||
{selectedIssue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{selectedIssue.body ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm">{selectedIssue.body}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Open in GitHub CTA */}
|
||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
View comments, add reactions, and more on GitHub.
|
||||
</p>
|
||||
<Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Full Issue on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onOpenExternal: () => void;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
|
||||
function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{issue.state === 'OPEN' ? (
|
||||
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{issue.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{issue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 flex-wrap">
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenExternal();
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
421
apps/ui/src/components/views/github-prs-view.tsx
Normal file
421
apps/ui/src/components/views/github-prs-view.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
GitPullRequest,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
Circle,
|
||||
X,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { getElectronAPI, GitHubPR } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function GitHubPRsView() {
|
||||
const [openPRs, setOpenPRs] = useState<GitHubPR[]>([]);
|
||||
const [mergedPRs, setMergedPRs] = useState<GitHubPR[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
const fetchPRs = useCallback(async () => {
|
||||
if (!currentProject?.path) {
|
||||
setError('No project selected');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const api = getElectronAPI();
|
||||
if (api.github) {
|
||||
const result = await api.github.listPRs(currentProject.path);
|
||||
if (result.success) {
|
||||
setOpenPRs(result.openPRs || []);
|
||||
setMergedPRs(result.mergedPRs || []);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch pull requests');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GitHubPRsView] Error fetching PRs:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch pull requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPRs();
|
||||
}, [fetchPRs]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
fetchPRs();
|
||||
}, [fetchPRs]);
|
||||
|
||||
const handleOpenInGitHub = useCallback((url: string) => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink(url);
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getReviewStatus = (pr: GitHubPR) => {
|
||||
if (pr.isDraft) return { label: 'Draft', color: 'text-muted-foreground', bg: 'bg-muted' };
|
||||
switch (pr.reviewDecision) {
|
||||
case 'APPROVED':
|
||||
return { label: 'Approved', color: 'text-green-500', bg: 'bg-green-500/10' };
|
||||
case 'CHANGES_REQUESTED':
|
||||
return { label: 'Changes requested', color: 'text-orange-500', bg: 'bg-orange-500/10' };
|
||||
case 'REVIEW_REQUIRED':
|
||||
return { label: 'Review required', color: 'text-yellow-500', bg: 'bg-yellow-500/10' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-destructive/10 mb-4">
|
||||
<GitPullRequest className="h-12 w-12 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPRs = openPRs.length + mergedPRs.length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* PR List */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden border-r border-border',
|
||||
selectedPR ? 'w-80' : 'flex-1'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||
<GitPullRequest className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Pull Requests</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalPRs === 0
|
||||
? 'No pull requests found'
|
||||
: `${openPRs.length} open, ${mergedPRs.length} merged`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PR List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{totalPRs === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<GitPullRequest className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium mb-2">No Pull Requests</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This repository has no pull requests yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{/* Open PRs */}
|
||||
{openPRs.map((pr) => (
|
||||
<PRRow
|
||||
key={pr.number}
|
||||
pr={pr}
|
||||
isSelected={selectedPR?.number === pr.number}
|
||||
onClick={() => setSelectedPR(pr)}
|
||||
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
||||
formatDate={formatDate}
|
||||
getReviewStatus={getReviewStatus}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Merged PRs Section */}
|
||||
{mergedPRs.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||
Merged ({mergedPRs.length})
|
||||
</div>
|
||||
{mergedPRs.map((pr) => (
|
||||
<PRRow
|
||||
key={pr.number}
|
||||
pr={pr}
|
||||
isSelected={selectedPR?.number === pr.number}
|
||||
onClick={() => setSelectedPR(pr)}
|
||||
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
||||
formatDate={formatDate}
|
||||
getReviewStatus={getReviewStatus}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PR Detail Panel */}
|
||||
{selectedPR && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedPR.state === 'MERGED' ? (
|
||||
<GitMerge className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
) : (
|
||||
<GitPullRequest className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
#{selectedPR.number} {selectedPR.title}
|
||||
</span>
|
||||
{selectedPR.isDraft && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenInGitHub(selectedPR.url)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Open in GitHub
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PR Detail Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
selectedPR.state === 'MERGED'
|
||||
? 'bg-purple-500/10 text-purple-500'
|
||||
: selectedPR.isDraft
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-green-500/10 text-green-500'
|
||||
)}
|
||||
>
|
||||
{selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'}
|
||||
</span>
|
||||
{getReviewStatus(selectedPR) && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
getReviewStatus(selectedPR)!.bg,
|
||||
getReviewStatus(selectedPR)!.color
|
||||
)}
|
||||
>
|
||||
{getReviewStatus(selectedPR)!.label}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
|
||||
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Branch info */}
|
||||
{selectedPR.headRefName && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs text-muted-foreground">Branch:</span>
|
||||
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{selectedPR.headRefName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{selectedPR.labels.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||
{selectedPR.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{selectedPR.body ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm">{selectedPR.body}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Open in GitHub CTA */}
|
||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
View code changes, comments, and reviews on GitHub.
|
||||
</p>
|
||||
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Full PR on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PRRowProps {
|
||||
pr: GitHubPR;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onOpenExternal: () => void;
|
||||
formatDate: (date: string) => string;
|
||||
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
|
||||
}
|
||||
|
||||
function PRRow({
|
||||
pr,
|
||||
isSelected,
|
||||
onClick,
|
||||
onOpenExternal,
|
||||
formatDate,
|
||||
getReviewStatus,
|
||||
}: PRRowProps) {
|
||||
const reviewStatus = getReviewStatus(pr);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{pr.state === 'MERGED' ? (
|
||||
<GitMerge className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<GitPullRequest className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{pr.title}</span>
|
||||
{pr.isDraft && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground flex-shrink-0">
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{pr.number} opened {formatDate(pr.createdAt)} by {pr.author.login}
|
||||
</span>
|
||||
{pr.headRefName && (
|
||||
<span className="text-xs text-muted-foreground font-mono bg-muted px-1 rounded">
|
||||
{pr.headRefName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{/* Review Status */}
|
||||
{reviewStatus && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 text-[10px] font-medium rounded',
|
||||
reviewStatus.bg,
|
||||
reviewStatus.color
|
||||
)}
|
||||
>
|
||||
{reviewStatus.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{pr.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenExternal();
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -92,12 +92,77 @@ export interface RunningAgentsAPI {
|
||||
getAll: () => Promise<RunningAgentsResult>;
|
||||
}
|
||||
|
||||
// GitHub types
|
||||
export interface GitHubLabel {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GitHubAuthor {
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface GitHubIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface GitHubPR {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
isDraft: boolean;
|
||||
headRefName: string;
|
||||
reviewDecision: string | null;
|
||||
mergeable: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface GitHubRemoteStatus {
|
||||
hasGitHubRemote: boolean;
|
||||
remoteUrl: string | null;
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
}
|
||||
|
||||
export interface GitHubAPI {
|
||||
checkRemote: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
hasGitHubRemote?: boolean;
|
||||
remoteUrl?: string | null;
|
||||
owner?: string | null;
|
||||
repo?: string | null;
|
||||
error?: string;
|
||||
}>;
|
||||
listIssues: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
openIssues?: GitHubIssue[];
|
||||
closedIssues?: GitHubIssue[];
|
||||
error?: string;
|
||||
}>;
|
||||
listPRs: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
openPRs?: GitHubPR[];
|
||||
mergedPRs?: GitHubPR[];
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Feature Suggestions types
|
||||
export interface FeatureSuggestion {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
priority: number;
|
||||
reasoning: string;
|
||||
}
|
||||
@@ -326,6 +391,7 @@ export interface ElectronAPI {
|
||||
autoMode?: AutoModeAPI;
|
||||
features?: FeaturesAPI;
|
||||
runningAgents?: RunningAgentsAPI;
|
||||
github?: GitHubAPI;
|
||||
enhancePrompt?: {
|
||||
enhance: (
|
||||
originalText: string,
|
||||
@@ -873,6 +939,9 @@ const getMockElectronAPI = (): ElectronAPI => {
|
||||
// Mock Running Agents API
|
||||
runningAgents: createMockRunningAgentsAPI(),
|
||||
|
||||
// Mock GitHub API
|
||||
github: createMockGitHubAPI(),
|
||||
|
||||
// Mock Claude API
|
||||
claude: {
|
||||
getUsage: async () => {
|
||||
@@ -1975,12 +2044,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: 'Code Smell',
|
||||
description: 'Extract duplicate validation logic into reusable utility',
|
||||
steps: [
|
||||
'Identify all files with similar validation patterns',
|
||||
'Create a validation utilities module',
|
||||
'Replace duplicate code with utility calls',
|
||||
'Add unit tests for the new utilities',
|
||||
],
|
||||
priority: 1,
|
||||
reasoning: 'Reduces code duplication and improves maintainability',
|
||||
},
|
||||
@@ -1988,12 +2051,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-1`,
|
||||
category: 'Complexity',
|
||||
description: 'Break down large handleSubmit function into smaller functions',
|
||||
steps: [
|
||||
'Identify the handleSubmit function in form components',
|
||||
'Extract validation logic into separate function',
|
||||
'Extract API call logic into separate function',
|
||||
'Extract success/error handling into separate functions',
|
||||
],
|
||||
priority: 2,
|
||||
reasoning: 'Function is too long and handles multiple responsibilities',
|
||||
},
|
||||
@@ -2001,12 +2058,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-2`,
|
||||
category: 'Architecture',
|
||||
description: 'Move business logic out of React components into hooks',
|
||||
steps: [
|
||||
'Identify business logic in component files',
|
||||
'Create custom hooks for reusable logic',
|
||||
'Update components to use the new hooks',
|
||||
'Add tests for the extracted hooks',
|
||||
],
|
||||
priority: 3,
|
||||
reasoning: 'Improves separation of concerns and testability',
|
||||
},
|
||||
@@ -2019,12 +2070,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: 'High',
|
||||
description: 'Sanitize user input before rendering to prevent XSS',
|
||||
steps: [
|
||||
'Audit all places where user input is rendered',
|
||||
'Implement input sanitization using DOMPurify',
|
||||
'Add Content-Security-Policy headers',
|
||||
'Test with common XSS payloads',
|
||||
],
|
||||
priority: 1,
|
||||
reasoning: 'User input is rendered without proper sanitization',
|
||||
},
|
||||
@@ -2032,12 +2077,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-1`,
|
||||
category: 'Medium',
|
||||
description: 'Add rate limiting to authentication endpoints',
|
||||
steps: [
|
||||
'Implement rate limiting middleware',
|
||||
'Configure limits for login attempts',
|
||||
'Add account lockout after failed attempts',
|
||||
'Log suspicious activity',
|
||||
],
|
||||
priority: 2,
|
||||
reasoning: 'Prevents brute force attacks on authentication',
|
||||
},
|
||||
@@ -2045,12 +2084,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-2`,
|
||||
category: 'Low',
|
||||
description: 'Remove sensitive information from error messages',
|
||||
steps: [
|
||||
'Audit error handling in API routes',
|
||||
'Create generic error messages for production',
|
||||
'Log detailed errors server-side only',
|
||||
'Implement proper error boundaries',
|
||||
],
|
||||
priority: 3,
|
||||
reasoning: 'Error messages may leak implementation details',
|
||||
},
|
||||
@@ -2063,12 +2096,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: 'Rendering',
|
||||
description: 'Add React.memo to prevent unnecessary re-renders',
|
||||
steps: [
|
||||
'Profile component renders with React DevTools',
|
||||
'Identify components that re-render unnecessarily',
|
||||
'Wrap pure components with React.memo',
|
||||
'Use useCallback for event handlers passed as props',
|
||||
],
|
||||
priority: 1,
|
||||
reasoning: "Components re-render even when props haven't changed",
|
||||
},
|
||||
@@ -2076,12 +2103,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-1`,
|
||||
category: 'Bundle Size',
|
||||
description: 'Implement code splitting for route components',
|
||||
steps: [
|
||||
'Use React.lazy for route components',
|
||||
'Add Suspense boundaries with loading states',
|
||||
'Analyze bundle with webpack-bundle-analyzer',
|
||||
'Consider dynamic imports for heavy libraries',
|
||||
],
|
||||
priority: 2,
|
||||
reasoning: 'Initial bundle is larger than necessary',
|
||||
},
|
||||
@@ -2089,12 +2110,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-2`,
|
||||
category: 'Caching',
|
||||
description: 'Add memoization for expensive computations',
|
||||
steps: [
|
||||
'Identify expensive calculations in render',
|
||||
'Use useMemo for derived data',
|
||||
'Consider using react-query for server state',
|
||||
'Add caching headers for static assets',
|
||||
],
|
||||
priority: 3,
|
||||
reasoning: 'Expensive computations run on every render',
|
||||
},
|
||||
@@ -2107,12 +2122,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: 'User Experience',
|
||||
description: 'Add dark mode toggle with system preference detection',
|
||||
steps: [
|
||||
'Create a ThemeProvider context to manage theme state',
|
||||
'Add a toggle component in the settings or header',
|
||||
'Implement CSS variables for theme colors',
|
||||
'Add localStorage persistence for user preference',
|
||||
],
|
||||
priority: 1,
|
||||
reasoning: 'Dark mode is a standard feature that improves accessibility and user comfort',
|
||||
},
|
||||
@@ -2120,11 +2129,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-1`,
|
||||
category: 'Performance',
|
||||
description: 'Implement lazy loading for heavy components',
|
||||
steps: [
|
||||
'Identify components that are heavy or rarely used',
|
||||
'Use React.lazy() and Suspense for code splitting',
|
||||
'Add loading states for lazy-loaded components',
|
||||
],
|
||||
priority: 2,
|
||||
reasoning: 'Improves initial load time and reduces bundle size',
|
||||
},
|
||||
@@ -2132,12 +2136,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
|
||||
id: `suggestion-${Date.now()}-2`,
|
||||
category: 'Accessibility',
|
||||
description: 'Add keyboard navigation support throughout the app',
|
||||
steps: [
|
||||
'Implement focus management for modals and dialogs',
|
||||
'Add keyboard shortcuts for common actions',
|
||||
'Ensure all interactive elements are focusable',
|
||||
'Add ARIA labels and roles where needed',
|
||||
],
|
||||
priority: 3,
|
||||
reasoning: 'Improves accessibility for users who rely on keyboard navigation',
|
||||
},
|
||||
@@ -2604,6 +2602,38 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
|
||||
};
|
||||
}
|
||||
|
||||
// Mock GitHub API implementation
|
||||
function createMockGitHubAPI(): GitHubAPI {
|
||||
return {
|
||||
checkRemote: async (projectPath: string) => {
|
||||
console.log('[Mock] Checking GitHub remote for:', projectPath);
|
||||
return {
|
||||
success: true,
|
||||
hasGitHubRemote: false,
|
||||
remoteUrl: null,
|
||||
owner: null,
|
||||
repo: null,
|
||||
};
|
||||
},
|
||||
listIssues: async (projectPath: string) => {
|
||||
console.log('[Mock] Listing GitHub issues for:', projectPath);
|
||||
return {
|
||||
success: true,
|
||||
openIssues: [],
|
||||
closedIssues: [],
|
||||
};
|
||||
},
|
||||
listPRs: async (projectPath: string) => {
|
||||
console.log('[Mock] Listing GitHub PRs for:', projectPath);
|
||||
return {
|
||||
success: true,
|
||||
openPRs: [],
|
||||
mergedPRs: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Utility functions for project management
|
||||
|
||||
export interface Project {
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import http, { Server } from 'http';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||
|
||||
// Development environment
|
||||
const isDev = !app.isPackaged;
|
||||
@@ -274,12 +275,22 @@ async function startStaticServer(): Promise<void> {
|
||||
* Start the backend server
|
||||
*/
|
||||
async function startServer(): Promise<void> {
|
||||
let command: string;
|
||||
// Find Node.js executable (handles desktop launcher scenarios)
|
||||
const nodeResult = findNodeExecutable({
|
||||
skipSearch: isDev,
|
||||
logger: (msg: string) => console.log(`[Electron] ${msg}`),
|
||||
});
|
||||
const command = nodeResult.nodePath;
|
||||
|
||||
// Validate that the found Node executable actually exists
|
||||
if (command !== 'node' && !fs.existsSync(command)) {
|
||||
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||
}
|
||||
|
||||
let args: string[];
|
||||
let serverPath: string;
|
||||
|
||||
if (isDev) {
|
||||
command = 'node';
|
||||
serverPath = path.join(__dirname, '../../server/src/index.ts');
|
||||
|
||||
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
|
||||
@@ -302,7 +313,6 @@ async function startServer(): Promise<void> {
|
||||
|
||||
args = [tsxCliPath, 'watch', serverPath];
|
||||
} else {
|
||||
command = 'node';
|
||||
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||
args = [serverPath];
|
||||
|
||||
@@ -315,8 +325,15 @@ async function startServer(): Promise<void> {
|
||||
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||
: path.join(__dirname, '../../server/node_modules');
|
||||
|
||||
// Build enhanced PATH that includes Node.js directory (cross-platform)
|
||||
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
|
||||
if (enhancedPath !== process.env.PATH) {
|
||||
console.log(`[Electron] Enhanced PATH with Node directory: ${path.dirname(command)}`);
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: enhancedPath,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath('userData'),
|
||||
NODE_PATH: serverNodeModules,
|
||||
@@ -511,6 +528,16 @@ app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
} catch (error) {
|
||||
console.error('[Electron] Failed to start:', error);
|
||||
const errorMessage = (error as Error).message;
|
||||
const isNodeError = errorMessage.includes('Node.js');
|
||||
dialog.showErrorBox(
|
||||
'Automaker Failed to Start',
|
||||
`The application failed to start.\n\n${errorMessage}\n\n${
|
||||
isNodeError
|
||||
? 'Please install Node.js from https://nodejs.org or via a package manager (Homebrew, nvm, fnm).'
|
||||
: 'Please check the application logs for more details.'
|
||||
}`
|
||||
);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
|
||||
6
apps/ui/src/routes/github-issues.tsx
Normal file
6
apps/ui/src/routes/github-issues.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { GitHubIssuesView } from '@/components/views/github-issues-view';
|
||||
|
||||
export const Route = createFileRoute('/github-issues')({
|
||||
component: GitHubIssuesView,
|
||||
});
|
||||
6
apps/ui/src/routes/github-prs.tsx
Normal file
6
apps/ui/src/routes/github-prs.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { GitHubPRsView } from '@/components/views/github-prs-view';
|
||||
|
||||
export const Route = createFileRoute('/github-prs')({
|
||||
component: GitHubPRsView,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
306
apps/ui/tests/e2e-testing-guide.md
Normal file
306
apps/ui/tests/e2e-testing-guide.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# E2E Testing Guide
|
||||
|
||||
Best practices and patterns for writing reliable, non-flaky Playwright e2e tests in this codebase.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **No arbitrary timeouts** - Never use `page.waitForTimeout()`. Always wait for specific conditions.
|
||||
2. **Use data-testid attributes** - Prefer `[data-testid="..."]` selectors over CSS classes or text content.
|
||||
3. **Clean up after tests** - Use unique temp directories and clean them up in `afterAll`.
|
||||
4. **Test isolation** - Each test should be independent and not rely on state from other tests.
|
||||
|
||||
## Setting Up Test State
|
||||
|
||||
### Use Setup Utilities (Recommended)
|
||||
|
||||
Use the provided utility functions to set up localStorage state. These utilities hide the internal store structure and version details, making tests more maintainable.
|
||||
|
||||
```typescript
|
||||
import { setupWelcomeView, setupRealProject } from './utils';
|
||||
|
||||
// Show welcome view with workspace directory configured
|
||||
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
||||
|
||||
// Show welcome view with recent projects
|
||||
await setupWelcomeView(page, {
|
||||
workspaceDir: TEST_TEMP_DIR,
|
||||
recentProjects: [
|
||||
{
|
||||
id: 'project-123',
|
||||
name: 'My Project',
|
||||
path: '/path/to/project',
|
||||
lastOpened: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Set up a real project on the filesystem
|
||||
await setupRealProject(page, projectPath, projectName, {
|
||||
setAsCurrent: true, // Opens board view (default)
|
||||
});
|
||||
```
|
||||
|
||||
### Why Use Utilities Instead of Raw localStorage
|
||||
|
||||
1. **Version management** - Store versions are centralized in one place
|
||||
2. **Less brittle** - If store structure changes, update one file instead of every test
|
||||
3. **Cleaner tests** - Focus on test logic, not setup boilerplate
|
||||
4. **Type safety** - Utilities provide typed interfaces for test data
|
||||
|
||||
### Manual LocalStorage Setup (Advanced)
|
||||
|
||||
If you need custom setup not covered by utilities, use `page.addInitScript()`.
|
||||
Store versions are defined in `tests/utils/project/setup.ts`:
|
||||
|
||||
- `APP_STORE`: version 2 (matches `app-store.ts`)
|
||||
- `SETUP_STORE`: version 0 (matches `setup-store.ts` default)
|
||||
|
||||
### Temp Directory Management
|
||||
|
||||
Create unique temp directories for test isolation:
|
||||
|
||||
```typescript
|
||||
import { createTempDirPath, cleanupTempDir } from './utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('my-test-name');
|
||||
|
||||
test.describe('My Tests', () => {
|
||||
test.beforeAll(async () => {
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
cleanupTempDir(TEST_TEMP_DIR);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Waiting for Elements
|
||||
|
||||
### Prefer `toBeVisible()` over `waitForSelector()`
|
||||
|
||||
```typescript
|
||||
// Good - uses Playwright's auto-waiting with expect
|
||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Avoid - manual waiting
|
||||
await page.waitForSelector('[data-testid="welcome-view"]');
|
||||
```
|
||||
|
||||
### Wait for network idle after navigation
|
||||
|
||||
```typescript
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
```
|
||||
|
||||
### Use appropriate timeouts
|
||||
|
||||
- Quick UI updates: 5000ms (default)
|
||||
- Page loads/navigation: 10000ms
|
||||
- Async operations (API calls, file system): 15000ms
|
||||
|
||||
```typescript
|
||||
// Fast UI element
|
||||
await expect(button).toBeVisible();
|
||||
|
||||
// Page load
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Async operation completion
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||
```
|
||||
|
||||
## Element Selection
|
||||
|
||||
### Use data-testid attributes
|
||||
|
||||
```typescript
|
||||
// Good - stable selector
|
||||
const button = page.locator('[data-testid="create-new-project"]');
|
||||
|
||||
// Avoid - brittle selectors
|
||||
const button = page.locator('.btn-primary');
|
||||
const button = page.getByText('Create');
|
||||
```
|
||||
|
||||
### Scope selectors when needed
|
||||
|
||||
When text appears in multiple places, scope to a parent:
|
||||
|
||||
```typescript
|
||||
// Bad - might match multiple elements
|
||||
await expect(page.getByText(projectName)).toBeVisible();
|
||||
|
||||
// Good - scoped to specific container
|
||||
await expect(page.locator('[data-testid="project-selector"]').getByText(projectName)).toBeVisible();
|
||||
```
|
||||
|
||||
### Handle strict mode violations
|
||||
|
||||
If a selector matches multiple elements:
|
||||
|
||||
```typescript
|
||||
// Use .first() if you need the first match
|
||||
await page.locator('[data-testid="item"]').first().click();
|
||||
|
||||
// Or scope to a unique parent
|
||||
await page.locator('[data-testid="sidebar"]').locator('[data-testid="item"]').click();
|
||||
```
|
||||
|
||||
## Clicking Elements
|
||||
|
||||
### Always verify visibility before clicking
|
||||
|
||||
```typescript
|
||||
const button = page.locator('[data-testid="submit"]');
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
```
|
||||
|
||||
### Handle dialogs that may close quickly
|
||||
|
||||
Some dialogs may appear briefly or auto-close. Don't rely on clicking them:
|
||||
|
||||
```typescript
|
||||
// Instead of trying to close a dialog that might disappear:
|
||||
// await expect(dialog).toBeVisible();
|
||||
// await closeButton.click(); // May fail if dialog closes first
|
||||
|
||||
// Just verify the end state:
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||
```
|
||||
|
||||
## Filesystem Verification
|
||||
|
||||
Verify files were created after async operations:
|
||||
|
||||
```typescript
|
||||
// Wait for UI to confirm operation completed first
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Then verify filesystem
|
||||
const projectPath = path.join(TEST_TEMP_DIR, projectName);
|
||||
expect(fs.existsSync(projectPath)).toBe(true);
|
||||
|
||||
const appSpecPath = path.join(projectPath, '.automaker', 'app_spec.txt');
|
||||
expect(fs.existsSync(appSpecPath)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(appSpecPath, 'utf-8');
|
||||
expect(content).toContain(projectName);
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Use descriptive test names
|
||||
|
||||
```typescript
|
||||
test('should create a new blank project from welcome view', async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Group related tests with describe blocks
|
||||
|
||||
```typescript
|
||||
test.describe('Project Creation', () => {
|
||||
test('should create a new blank project from welcome view', ...);
|
||||
test('should create a project from template', ...);
|
||||
});
|
||||
```
|
||||
|
||||
### Use serial mode when tests depend on each other
|
||||
|
||||
```typescript
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Waiting for either of two outcomes
|
||||
|
||||
When multiple outcomes are possible (e.g., dialog or direct navigation):
|
||||
|
||||
```typescript
|
||||
// Wait for either the dialog or the board view
|
||||
await Promise.race([
|
||||
initDialog.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}),
|
||||
boardView.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}),
|
||||
]);
|
||||
|
||||
// Then handle whichever appeared
|
||||
if (await initDialog.isVisible()) {
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
await expect(boardView).toBeVisible();
|
||||
```
|
||||
|
||||
### Generating unique test data
|
||||
|
||||
```typescript
|
||||
const projectName = `test-project-${Date.now()}`;
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test
|
||||
|
||||
# Run specific test file
|
||||
npm run test -- project-creation.spec.ts
|
||||
|
||||
# Run with headed browser (see what's happening)
|
||||
npm run test:headed -- project-creation.spec.ts
|
||||
|
||||
# Run multiple times to check for flakiness
|
||||
npm run test -- project-creation.spec.ts --repeat-each=5
|
||||
```
|
||||
|
||||
## Debugging Failed Tests
|
||||
|
||||
1. Check the screenshot in `test-results/`
|
||||
2. Read the error context markdown file in `test-results/`
|
||||
3. Run with `--headed` to watch the test
|
||||
4. Add `await page.pause()` to pause execution at a specific point
|
||||
|
||||
## Available Test Utilities
|
||||
|
||||
Import from `./utils`:
|
||||
|
||||
### State Setup Utilities
|
||||
|
||||
- `setupWelcomeView(page, options?)` - Set up empty state showing welcome view
|
||||
- `options.workspaceDir` - Pre-configure workspace directory
|
||||
- `options.recentProjects` - Add projects to recent list (not current)
|
||||
- `setupRealProject(page, path, name, options?)` - Set up state with a real filesystem project
|
||||
- `options.setAsCurrent` - Open board view (default: true)
|
||||
- `options.additionalProjects` - Add more projects to list
|
||||
- `setupMockProject(page)` - Set up mock project for unit-style tests
|
||||
- `setupComplete(page)` - Mark setup wizard as complete
|
||||
|
||||
### Filesystem Utilities
|
||||
|
||||
- `createTempDirPath(prefix)` - Create unique temp directory path
|
||||
- `cleanupTempDir(path)` - Remove temp directory
|
||||
- `createTestGitRepo(tempDir)` - Create a git repo for testing
|
||||
|
||||
### Waiting Utilities
|
||||
|
||||
- `waitForNetworkIdle(page)` - Wait for network to be idle
|
||||
- `waitForElement(page, testId)` - Wait for element by test ID
|
||||
|
||||
### Async File Verification
|
||||
|
||||
Use `expect().toPass()` for polling filesystem operations:
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
}).toPass({ timeout: 10000 });
|
||||
```
|
||||
|
||||
See `tests/utils/index.ts` for the full list of available utilities.
|
||||
125
apps/ui/tests/open-project.spec.ts
Normal file
125
apps/ui/tests/open-project.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Open Project End-to-End Test
|
||||
*
|
||||
* Tests opening an existing project directory from the welcome view.
|
||||
* This verifies that:
|
||||
* 1. An existing directory can be opened as a project
|
||||
* 2. The .automaker directory is initialized if it doesn't exist
|
||||
* 3. The project is loaded and shown in the board view
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils';
|
||||
|
||||
// Create unique temp dir for this test run
|
||||
const TEST_TEMP_DIR = createTempDirPath('open-project-test');
|
||||
|
||||
test.describe('Open Project', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Create test temp directory
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
cleanupTempDir(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
test('should open an existing project directory from recent projects', async ({ page }) => {
|
||||
const projectName = `existing-project-${Date.now()}`;
|
||||
const projectPath = path.join(TEST_TEMP_DIR, projectName);
|
||||
const projectId = `project-${Date.now()}`;
|
||||
|
||||
// Create the project directory with some files to simulate an existing codebase
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
|
||||
// Create a package.json to simulate a real project
|
||||
fs.writeFileSync(
|
||||
path.join(projectPath, 'package.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: projectName,
|
||||
version: '1.0.0',
|
||||
description: 'A test project for e2e testing',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
// Create a README.md
|
||||
fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${projectName}\n\nA test project.`);
|
||||
|
||||
// Create a src directory with an index.ts file
|
||||
fs.mkdirSync(path.join(projectPath, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectPath, 'src', 'index.ts'),
|
||||
'export const hello = () => console.log("Hello World");'
|
||||
);
|
||||
|
||||
// Set up welcome view with the project in recent projects (but NOT as current project)
|
||||
await setupWelcomeView(page, {
|
||||
recentProjects: [
|
||||
{
|
||||
id: projectId,
|
||||
name: projectName,
|
||||
path: projectPath,
|
||||
lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Navigate to the app
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for welcome view to be visible
|
||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify we see the "Recent Projects" section
|
||||
await expect(page.getByText('Recent Projects')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click on the recent project to open it
|
||||
const recentProjectCard = page.locator(`[data-testid="recent-project-${projectId}"]`);
|
||||
await expect(recentProjectCard).toBeVisible();
|
||||
await recentProjectCard.click();
|
||||
|
||||
// Wait for the board view to appear (project was opened)
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify the project name appears in the project selector (sidebar)
|
||||
await expect(
|
||||
page.locator('[data-testid="project-selector"]').getByText(projectName)
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify .automaker directory was created (initialized for the first time)
|
||||
// Use polling since file creation may be async
|
||||
const automakerDir = path.join(projectPath, '.automaker');
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(automakerDir)).toBe(true);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Verify the required structure was created by initializeProject:
|
||||
// - .automaker/categories.json
|
||||
// - .automaker/features directory
|
||||
// - .automaker/context directory
|
||||
// Note: app_spec.txt is NOT created automatically for existing projects
|
||||
const categoriesPath = path.join(automakerDir, 'categories.json');
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(categoriesPath)).toBe(true);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Verify subdirectories were created
|
||||
expect(fs.existsSync(path.join(automakerDir, 'features'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(automakerDir, 'context'))).toBe(true);
|
||||
|
||||
// Verify the original project files still exist (weren't modified)
|
||||
expect(fs.existsSync(path.join(projectPath, 'package.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(projectPath, 'README.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(projectPath, 'src', 'index.ts'))).toBe(true);
|
||||
});
|
||||
});
|
||||
188
apps/ui/tests/project-creation.spec.ts
Normal file
188
apps/ui/tests/project-creation.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Project Creation End-to-End Tests
|
||||
*
|
||||
* Tests the project creation flows:
|
||||
* 1. Creating a new blank project from the welcome view
|
||||
* 2. Creating a new project from a GitHub template
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils';
|
||||
|
||||
// Create unique temp dir for this test run
|
||||
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
|
||||
|
||||
test.describe('Project Creation', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Create test temp directory
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
cleanupTempDir(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
test('should create a new blank project from welcome view', async ({ page }) => {
|
||||
const projectName = `test-project-${Date.now()}`;
|
||||
|
||||
// Set up welcome view with workspace directory pre-configured
|
||||
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
||||
|
||||
// Navigate to the app
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for welcome view to be visible
|
||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the "Create New Project" dropdown button
|
||||
const createButton = page.locator('[data-testid="create-new-project"]');
|
||||
await expect(createButton).toBeVisible();
|
||||
await createButton.click();
|
||||
|
||||
// Click "Quick Setup" option from the dropdown
|
||||
const quickSetupOption = page.locator('[data-testid="quick-setup-option"]');
|
||||
await expect(quickSetupOption).toBeVisible();
|
||||
await quickSetupOption.click();
|
||||
|
||||
// Wait for the new project modal to appear
|
||||
const modal = page.locator('[data-testid="new-project-modal"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Enter the project name
|
||||
const projectNameInput = page.locator('[data-testid="project-name-input"]');
|
||||
await expect(projectNameInput).toBeVisible();
|
||||
await projectNameInput.fill(projectName);
|
||||
|
||||
// Verify the workspace directory is shown (from our pre-configured localStorage)
|
||||
// Wait for workspace to be loaded (it shows "Will be created at:" when ready)
|
||||
await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the Create Project button
|
||||
const createProjectButton = page.locator('[data-testid="confirm-create-project"]');
|
||||
await expect(createProjectButton).toBeVisible();
|
||||
await createProjectButton.click();
|
||||
|
||||
// Wait for project creation to complete
|
||||
// The app may show an init dialog briefly and then navigate to board view
|
||||
// We just need to verify we end up on the board view with our project
|
||||
|
||||
// Wait for the board view - this confirms the project was created and opened
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify the project name appears in the project selector (sidebar)
|
||||
await expect(
|
||||
page.locator('[data-testid="project-selector"]').getByText(projectName)
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the project was created in the filesystem
|
||||
const projectPath = path.join(TEST_TEMP_DIR, projectName);
|
||||
expect(fs.existsSync(projectPath)).toBe(true);
|
||||
|
||||
// Verify .automaker directory was created
|
||||
const automakerDir = path.join(projectPath, '.automaker');
|
||||
expect(fs.existsSync(automakerDir)).toBe(true);
|
||||
|
||||
// Verify app_spec.txt was created
|
||||
const appSpecPath = path.join(automakerDir, 'app_spec.txt');
|
||||
expect(fs.existsSync(appSpecPath)).toBe(true);
|
||||
|
||||
// Verify the app_spec.txt contains the project name
|
||||
const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8');
|
||||
expect(appSpecContent).toContain(projectName);
|
||||
});
|
||||
|
||||
test('should create a new project from GitHub template', async ({ page }) => {
|
||||
// Increase timeout for this test since git clone takes time
|
||||
test.setTimeout(60000);
|
||||
|
||||
const projectName = `template-project-${Date.now()}`;
|
||||
|
||||
// Set up welcome view with workspace directory pre-configured
|
||||
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
||||
|
||||
// Navigate to the app
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for welcome view to be visible
|
||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the "Create New Project" dropdown button
|
||||
const createButton = page.locator('[data-testid="create-new-project"]');
|
||||
await expect(createButton).toBeVisible();
|
||||
await createButton.click();
|
||||
|
||||
// Click "Quick Setup" option from the dropdown
|
||||
const quickSetupOption = page.locator('[data-testid="quick-setup-option"]');
|
||||
await expect(quickSetupOption).toBeVisible();
|
||||
await quickSetupOption.click();
|
||||
|
||||
// Wait for the new project modal to appear
|
||||
const modal = page.locator('[data-testid="new-project-modal"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Enter the project name first
|
||||
const projectNameInput = page.locator('[data-testid="project-name-input"]');
|
||||
await expect(projectNameInput).toBeVisible();
|
||||
await projectNameInput.fill(projectName);
|
||||
|
||||
// Wait for workspace directory to be loaded
|
||||
await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click on the "Starter Kit" tab
|
||||
const starterKitTab = modal.getByText('Starter Kit');
|
||||
await expect(starterKitTab).toBeVisible();
|
||||
await starterKitTab.click();
|
||||
|
||||
// Select the first template (Automaker Starter Kit)
|
||||
const firstTemplate = page.locator('[data-testid="template-automaker-starter-kit"]');
|
||||
await expect(firstTemplate).toBeVisible();
|
||||
await firstTemplate.click();
|
||||
|
||||
// Verify the template is selected (check mark should appear)
|
||||
await expect(firstTemplate.locator('.lucide-check')).toBeVisible();
|
||||
|
||||
// Click the Create Project button
|
||||
const createProjectButton = page.locator('[data-testid="confirm-create-project"]');
|
||||
await expect(createProjectButton).toBeVisible();
|
||||
await createProjectButton.click();
|
||||
|
||||
// Wait for git clone to complete and board view to appear
|
||||
// This takes longer due to the git clone operation
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 45000 });
|
||||
|
||||
// Verify the project name appears in the project selector (sidebar)
|
||||
await expect(
|
||||
page.locator('[data-testid="project-selector"]').getByText(projectName)
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the project was cloned in the filesystem
|
||||
const projectPath = path.join(TEST_TEMP_DIR, projectName);
|
||||
expect(fs.existsSync(projectPath)).toBe(true);
|
||||
|
||||
// Verify .automaker directory was created
|
||||
const automakerDir = path.join(projectPath, '.automaker');
|
||||
expect(fs.existsSync(automakerDir)).toBe(true);
|
||||
|
||||
// Verify app_spec.txt was created with template info
|
||||
const appSpecPath = path.join(automakerDir, 'app_spec.txt');
|
||||
expect(fs.existsSync(appSpecPath)).toBe(true);
|
||||
const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8');
|
||||
expect(appSpecContent).toContain(projectName);
|
||||
expect(appSpecContent).toContain('Automaker Starter Kit');
|
||||
|
||||
// Verify the template files were cloned (check for package.json which should exist in the template)
|
||||
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||
expect(fs.existsSync(packageJsonPath)).toBe(true);
|
||||
|
||||
// Verify it's a git repository (cloned from GitHub)
|
||||
const gitDir = path.join(projectPath, '.git');
|
||||
expect(fs.existsSync(gitDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,164 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Store version constants - centralized to avoid hardcoding across tests
|
||||
* These MUST match the versions used in the actual stores
|
||||
*/
|
||||
const STORE_VERSIONS = {
|
||||
APP_STORE: 2, // Must match app-store.ts persist version
|
||||
SETUP_STORE: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Project interface for test setup
|
||||
*/
|
||||
export interface TestProject {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
lastOpened?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for setting up the welcome view
|
||||
*/
|
||||
export interface WelcomeViewSetupOptions {
|
||||
/** Directory path to pre-configure as the workspace directory */
|
||||
workspaceDir?: string;
|
||||
/** Recent projects to show (but not as current project) */
|
||||
recentProjects?: TestProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage to show the welcome view with no current project
|
||||
* This is the cleanest way to test project creation flows
|
||||
*
|
||||
* @param page - Playwright page
|
||||
* @param options - Configuration options
|
||||
*/
|
||||
export async function setupWelcomeView(
|
||||
page: Page,
|
||||
options?: WelcomeViewSetupOptions
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
opts,
|
||||
versions,
|
||||
}: {
|
||||
opts: WelcomeViewSetupOptions | undefined;
|
||||
versions: typeof STORE_VERSIONS;
|
||||
}) => {
|
||||
// Set up empty app state (no current project) - shows welcome view
|
||||
const appState = {
|
||||
state: {
|
||||
projects: opts?.recentProjects || [],
|
||||
currentProject: null,
|
||||
currentView: 'welcome',
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: '', google: '' },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: versions.APP_STORE,
|
||||
};
|
||||
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: versions.SETUP_STORE,
|
||||
};
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
|
||||
// Set workspace directory if provided
|
||||
if (opts?.workspaceDir) {
|
||||
localStorage.setItem('automaker:lastProjectDir', opts.workspaceDir);
|
||||
}
|
||||
},
|
||||
{ opts: options, versions: STORE_VERSIONS }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project at a real filesystem path
|
||||
* Use this when testing with actual files on disk
|
||||
*
|
||||
* @param page - Playwright page
|
||||
* @param projectPath - Absolute path to the project directory
|
||||
* @param projectName - Display name for the project
|
||||
* @param options - Additional options
|
||||
*/
|
||||
export async function setupRealProject(
|
||||
page: Page,
|
||||
projectPath: string,
|
||||
projectName: string,
|
||||
options?: {
|
||||
/** Set as current project (opens board view) or just add to recent projects */
|
||||
setAsCurrent?: boolean;
|
||||
/** Additional recent projects to include */
|
||||
additionalProjects?: TestProject[];
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
path,
|
||||
name,
|
||||
opts,
|
||||
versions,
|
||||
}: {
|
||||
path: string;
|
||||
name: string;
|
||||
opts: typeof options;
|
||||
versions: typeof STORE_VERSIONS;
|
||||
}) => {
|
||||
const projectId = `project-${Date.now()}`;
|
||||
const project: TestProject = {
|
||||
id: projectId,
|
||||
name: name,
|
||||
path: path,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const allProjects = [project, ...(opts?.additionalProjects || [])];
|
||||
const currentProject = opts?.setAsCurrent !== false ? project : null;
|
||||
|
||||
const appState = {
|
||||
state: {
|
||||
projects: allProjects,
|
||||
currentProject: currentProject,
|
||||
currentView: currentProject ? 'board' : 'welcome',
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: '', google: '' },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: versions.APP_STORE,
|
||||
};
|
||||
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
||||
|
||||
// Mark setup as complete
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: versions.SETUP_STORE,
|
||||
};
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
},
|
||||
{ path: projectPath, name: projectName, opts: options, versions: STORE_VERSIONS }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project in localStorage to bypass the welcome screen
|
||||
* This simulates having opened a project before
|
||||
@@ -595,7 +754,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
||||
* Set up the app to skip the setup wizard (setup already complete)
|
||||
*/
|
||||
export async function setupComplete(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
await page.addInitScript((versions: typeof STORE_VERSIONS) => {
|
||||
// Mark setup as complete
|
||||
const setupState = {
|
||||
state: {
|
||||
@@ -604,11 +763,11 @@ export async function setupComplete(page: Page): Promise<void> {
|
||||
currentStep: 'complete',
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
version: versions.SETUP_STORE,
|
||||
};
|
||||
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
});
|
||||
}, STORE_VERSIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
386
libs/platform/src/node-finder.ts
Normal file
386
libs/platform/src/node-finder.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Cross-platform Node.js executable finder
|
||||
*
|
||||
* Handles finding Node.js when the app is launched from desktop environments
|
||||
* (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */
|
||||
const VERSION_DIR_PATTERN = /^v?\d+/;
|
||||
|
||||
/** Pattern to identify pre-release versions (beta, rc, alpha, nightly, canary) */
|
||||
const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i;
|
||||
|
||||
/** Result of finding Node.js executable */
|
||||
export interface NodeFinderResult {
|
||||
/** Path to the Node.js executable */
|
||||
nodePath: string;
|
||||
/** How Node.js was found */
|
||||
source:
|
||||
| 'homebrew'
|
||||
| 'system'
|
||||
| 'nvm'
|
||||
| 'fnm'
|
||||
| 'nvm-windows'
|
||||
| 'program-files'
|
||||
| 'scoop'
|
||||
| 'chocolatey'
|
||||
| 'which'
|
||||
| 'where'
|
||||
| 'fallback';
|
||||
}
|
||||
|
||||
/** Options for finding Node.js */
|
||||
export interface NodeFinderOptions {
|
||||
/** Skip the search and return 'node' immediately (useful for dev mode) */
|
||||
skipSearch?: boolean;
|
||||
/** Custom logger function */
|
||||
logger?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists and is executable
|
||||
* On Windows, only checks existence (X_OK is not meaningful)
|
||||
*/
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, fs.constants.X_OK is not meaningful - just check existence
|
||||
fs.accessSync(filePath, fs.constants.F_OK);
|
||||
} else {
|
||||
// On Unix-like systems, check for execute permission
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Node.js executable from version manager directories (NVM, fnm)
|
||||
* Uses semantic version sorting to prefer the latest stable version
|
||||
* Pre-release versions (beta, rc, alpha) are deprioritized but used as fallback
|
||||
*/
|
||||
function findNodeFromVersionManager(
|
||||
basePath: string,
|
||||
binSubpath: string = 'bin/node'
|
||||
): string | null {
|
||||
if (!fs.existsSync(basePath)) return null;
|
||||
|
||||
try {
|
||||
const allVersions = fs
|
||||
.readdirSync(basePath)
|
||||
.filter((v) => VERSION_DIR_PATTERN.test(v))
|
||||
// Semantic version sort - newest first using localeCompare with numeric option
|
||||
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
// Separate stable and pre-release versions, preferring stable
|
||||
const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v));
|
||||
const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v));
|
||||
|
||||
// Try stable versions first, then fall back to pre-release
|
||||
for (const version of [...stableVersions, ...preReleaseVersions]) {
|
||||
const nodePath = path.join(basePath, version, binSubpath);
|
||||
if (isExecutable(nodePath)) {
|
||||
return nodePath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory read failed, skip this location
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Node.js on macOS
|
||||
*/
|
||||
function findNodeMacOS(homeDir: string): NodeFinderResult | null {
|
||||
// Check Homebrew paths in order of preference
|
||||
const homebrewPaths = [
|
||||
// Apple Silicon
|
||||
'/opt/homebrew/bin/node',
|
||||
// Intel
|
||||
'/usr/local/bin/node',
|
||||
];
|
||||
|
||||
for (const nodePath of homebrewPaths) {
|
||||
if (isExecutable(nodePath)) {
|
||||
return { nodePath, source: 'homebrew' };
|
||||
}
|
||||
}
|
||||
|
||||
// System Node
|
||||
if (isExecutable('/usr/bin/node')) {
|
||||
return { nodePath: '/usr/bin/node', source: 'system' };
|
||||
}
|
||||
|
||||
// NVM installation
|
||||
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
|
||||
const nvmNode = findNodeFromVersionManager(nvmPath);
|
||||
if (nvmNode) {
|
||||
return { nodePath: nvmNode, source: 'nvm' };
|
||||
}
|
||||
|
||||
// fnm installation (multiple possible locations)
|
||||
const fnmPaths = [
|
||||
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
|
||||
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
|
||||
];
|
||||
|
||||
for (const fnmBasePath of fnmPaths) {
|
||||
const fnmNode = findNodeFromVersionManager(fnmBasePath);
|
||||
if (fnmNode) {
|
||||
return { nodePath: fnmNode, source: 'fnm' };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Node.js on Linux
|
||||
*/
|
||||
function findNodeLinux(homeDir: string): NodeFinderResult | null {
|
||||
// Common Linux paths
|
||||
const systemPaths = [
|
||||
'/usr/bin/node',
|
||||
'/usr/local/bin/node',
|
||||
// Snap installation
|
||||
'/snap/bin/node',
|
||||
];
|
||||
|
||||
for (const nodePath of systemPaths) {
|
||||
if (isExecutable(nodePath)) {
|
||||
return { nodePath, source: 'system' };
|
||||
}
|
||||
}
|
||||
|
||||
// NVM installation
|
||||
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
|
||||
const nvmNode = findNodeFromVersionManager(nvmPath);
|
||||
if (nvmNode) {
|
||||
return { nodePath: nvmNode, source: 'nvm' };
|
||||
}
|
||||
|
||||
// fnm installation
|
||||
const fnmPaths = [
|
||||
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
|
||||
path.join(homeDir, '.fnm', 'node-versions'),
|
||||
];
|
||||
|
||||
for (const fnmBasePath of fnmPaths) {
|
||||
const fnmNode = findNodeFromVersionManager(fnmBasePath);
|
||||
if (fnmNode) {
|
||||
return { nodePath: fnmNode, source: 'fnm' };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Node.js on Windows
|
||||
*/
|
||||
function findNodeWindows(homeDir: string): NodeFinderResult | null {
|
||||
// Program Files paths
|
||||
const programFilesPaths = [
|
||||
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'),
|
||||
];
|
||||
|
||||
for (const nodePath of programFilesPaths) {
|
||||
if (isExecutable(nodePath)) {
|
||||
return { nodePath, source: 'program-files' };
|
||||
}
|
||||
}
|
||||
|
||||
// NVM for Windows
|
||||
const nvmWindowsPath = path.join(
|
||||
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
||||
'nvm'
|
||||
);
|
||||
const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe');
|
||||
if (nvmNode) {
|
||||
return { nodePath: nvmNode, source: 'nvm-windows' };
|
||||
}
|
||||
|
||||
// fnm on Windows (prioritize canonical installation path over shell shims)
|
||||
const fnmWindowsPaths = [
|
||||
path.join(homeDir, '.fnm', 'node-versions'),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
||||
'fnm',
|
||||
'node-versions'
|
||||
),
|
||||
];
|
||||
|
||||
for (const fnmBasePath of fnmWindowsPaths) {
|
||||
const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe');
|
||||
if (fnmNode) {
|
||||
return { nodePath: fnmNode, source: 'fnm' };
|
||||
}
|
||||
}
|
||||
|
||||
// Scoop installation
|
||||
const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe');
|
||||
if (isExecutable(scoopPath)) {
|
||||
return { nodePath: scoopPath, source: 'scoop' };
|
||||
}
|
||||
|
||||
// Chocolatey installation
|
||||
const chocoPath = path.join(
|
||||
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
|
||||
'bin',
|
||||
'node.exe'
|
||||
);
|
||||
if (isExecutable(chocoPath)) {
|
||||
return { nodePath: chocoPath, source: 'chocolatey' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find Node.js using shell commands (which/where)
|
||||
*/
|
||||
function findNodeViaShell(
|
||||
platform: NodeJS.Platform,
|
||||
logger: (message: string) => void = () => {}
|
||||
): NodeFinderResult | null {
|
||||
try {
|
||||
const command = platform === 'win32' ? 'where node' : 'which node';
|
||||
const result = execSync(command, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
// 'where' on Windows can return multiple lines, take the first
|
||||
const nodePath = result.split(/\r?\n/)[0];
|
||||
|
||||
// Validate path: check for null bytes (security) and executable permission
|
||||
if (nodePath && !nodePath.includes('\x00') && isExecutable(nodePath)) {
|
||||
return {
|
||||
nodePath,
|
||||
source: platform === 'win32' ? 'where' : 'which',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Shell command failed (likely when launched from desktop without PATH)
|
||||
logger('Shell command failed to find Node.js (expected when launched from desktop)');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Node.js executable - handles desktop launcher scenarios where PATH is limited
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Result with path and source information
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { findNodeExecutable } from '@automaker/platform';
|
||||
*
|
||||
* // In development, skip the search
|
||||
* const result = findNodeExecutable({ skipSearch: isDev });
|
||||
* console.log(`Using Node.js from ${result.source}: ${result.nodePath}`);
|
||||
*
|
||||
* // Spawn a process with the found Node.js
|
||||
* spawn(result.nodePath, ['script.js']);
|
||||
* ```
|
||||
*/
|
||||
export function findNodeExecutable(options: NodeFinderOptions = {}): NodeFinderResult {
|
||||
const { skipSearch = false, logger = () => {} } = options;
|
||||
|
||||
// Skip search if requested (e.g., in development mode)
|
||||
if (skipSearch) {
|
||||
return { nodePath: 'node', source: 'fallback' };
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Platform-specific search
|
||||
let result: NodeFinderResult | null = null;
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
result = findNodeMacOS(homeDir);
|
||||
break;
|
||||
case 'linux':
|
||||
result = findNodeLinux(homeDir);
|
||||
break;
|
||||
case 'win32':
|
||||
result = findNodeWindows(homeDir);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
logger(`Found Node.js via ${result.source} at: ${result.nodePath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback - try shell resolution (works when launched from terminal)
|
||||
result = findNodeViaShell(platform, logger);
|
||||
if (result) {
|
||||
logger(`Found Node.js via ${result.source} at: ${result.nodePath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
logger('Could not find Node.js, falling back to "node"');
|
||||
return { nodePath: 'node', source: 'fallback' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an enhanced PATH that includes the Node.js directory
|
||||
* Useful for ensuring child processes can find Node.js
|
||||
*
|
||||
* @param nodePath - Path to the Node.js executable
|
||||
* @param currentPath - Current PATH environment variable
|
||||
* @returns Enhanced PATH with Node.js directory prepended if not already present
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||
*
|
||||
* const { nodePath } = findNodeExecutable();
|
||||
* const enhancedPath = buildEnhancedPath(nodePath, process.env.PATH);
|
||||
*
|
||||
* spawn(nodePath, ['script.js'], {
|
||||
* env: { ...process.env, PATH: enhancedPath }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function buildEnhancedPath(nodePath: string, currentPath: string = ''): string {
|
||||
// If using fallback 'node', don't modify PATH
|
||||
if (nodePath === 'node') {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
// Don't add if already present or if it's just '.'
|
||||
// Use path segment matching to avoid false positives (e.g., /opt/node vs /opt/node-v18)
|
||||
// Normalize paths for comparison to handle mixed separators on Windows
|
||||
const normalizedNodeDir = path.normalize(nodeDir);
|
||||
const pathSegments = currentPath.split(path.delimiter).map((s) => path.normalize(s));
|
||||
if (normalizedNodeDir === '.' || pathSegments.includes(normalizedNodeDir)) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
// Use platform-appropriate path separator
|
||||
// Handle empty currentPath without adding trailing delimiter
|
||||
if (!currentPath) {
|
||||
return nodeDir;
|
||||
}
|
||||
return `${nodeDir}${path.delimiter}${currentPath}`;
|
||||
}
|
||||
197
libs/platform/tests/node-finder.test.ts
Normal file
197
libs/platform/tests/node-finder.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '../src/node-finder.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
describe('node-finder', () => {
|
||||
describe('version sorting and pre-release filtering', () => {
|
||||
// Test the PRE_RELEASE_PATTERN logic indirectly
|
||||
const PRE_RELEASE_PATTERN = /-(beta|rc|alpha|nightly|canary|dev|pre)/i;
|
||||
|
||||
it('should identify pre-release versions correctly', () => {
|
||||
const preReleaseVersions = [
|
||||
'v20.0.0-beta',
|
||||
'v18.17.0-rc1',
|
||||
'v19.0.0-alpha',
|
||||
'v21.0.0-nightly',
|
||||
'v20.0.0-canary',
|
||||
'v18.0.0-dev',
|
||||
'v17.0.0-pre',
|
||||
];
|
||||
|
||||
for (const version of preReleaseVersions) {
|
||||
expect(PRE_RELEASE_PATTERN.test(version)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not match stable versions as pre-release', () => {
|
||||
const stableVersions = ['v18.17.0', 'v20.10.0', 'v16.20.2', '18.17.0', 'v21.0.0'];
|
||||
|
||||
for (const version of stableVersions) {
|
||||
expect(PRE_RELEASE_PATTERN.test(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should sort versions with numeric comparison', () => {
|
||||
const versions = ['v18.9.0', 'v18.17.0', 'v20.0.0', 'v8.0.0'];
|
||||
const sorted = [...versions].sort((a, b) =>
|
||||
b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })
|
||||
);
|
||||
|
||||
expect(sorted).toEqual(['v20.0.0', 'v18.17.0', 'v18.9.0', 'v8.0.0']);
|
||||
});
|
||||
|
||||
it('should prefer stable over pre-release when filtering', () => {
|
||||
const allVersions = ['v20.0.0-beta', 'v19.9.9', 'v18.17.0', 'v21.0.0-rc1'];
|
||||
|
||||
const stableVersions = allVersions.filter((v) => !PRE_RELEASE_PATTERN.test(v));
|
||||
const preReleaseVersions = allVersions.filter((v) => PRE_RELEASE_PATTERN.test(v));
|
||||
const prioritized = [...stableVersions, ...preReleaseVersions];
|
||||
|
||||
// Stable versions should come first
|
||||
expect(prioritized[0]).toBe('v19.9.9');
|
||||
expect(prioritized[1]).toBe('v18.17.0');
|
||||
// Pre-release versions should come after
|
||||
expect(prioritized[2]).toBe('v20.0.0-beta');
|
||||
expect(prioritized[3]).toBe('v21.0.0-rc1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNodeExecutable', () => {
|
||||
it("should return 'node' with fallback source when skipSearch is true", () => {
|
||||
const result = findNodeExecutable({ skipSearch: true });
|
||||
|
||||
expect(result.nodePath).toBe('node');
|
||||
expect(result.source).toBe('fallback');
|
||||
});
|
||||
|
||||
it('should call logger when node is found', () => {
|
||||
const logger = vi.fn();
|
||||
findNodeExecutable({ logger });
|
||||
|
||||
// Logger should be called at least once (either found or fallback message)
|
||||
expect(logger).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return a valid NodeFinderResult structure', () => {
|
||||
const result = findNodeExecutable();
|
||||
|
||||
expect(result).toHaveProperty('nodePath');
|
||||
expect(result).toHaveProperty('source');
|
||||
expect(typeof result.nodePath).toBe('string');
|
||||
expect(result.nodePath.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should find node on the current system', () => {
|
||||
// This test verifies that node can be found on the test machine
|
||||
const result = findNodeExecutable();
|
||||
|
||||
// Should find node since we're running in Node.js
|
||||
expect(result.nodePath).toBeDefined();
|
||||
|
||||
// Source should be one of the valid sources
|
||||
const validSources = [
|
||||
'homebrew',
|
||||
'system',
|
||||
'nvm',
|
||||
'fnm',
|
||||
'nvm-windows',
|
||||
'program-files',
|
||||
'scoop',
|
||||
'chocolatey',
|
||||
'which',
|
||||
'where',
|
||||
'fallback',
|
||||
];
|
||||
expect(validSources).toContain(result.source);
|
||||
});
|
||||
|
||||
it('should find an executable node binary', () => {
|
||||
const result = findNodeExecutable();
|
||||
|
||||
// Skip this test if fallback is used (node not found via path search)
|
||||
if (result.source === 'fallback') {
|
||||
expect(result.nodePath).toBe('node');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the found path is actually executable
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, just check file exists (X_OK is not meaningful)
|
||||
expect(() => fs.accessSync(result.nodePath, fs.constants.F_OK)).not.toThrow();
|
||||
} else {
|
||||
// On Unix-like systems, verify execute permission
|
||||
expect(() => fs.accessSync(result.nodePath, fs.constants.X_OK)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnhancedPath', () => {
|
||||
const delimiter = path.delimiter;
|
||||
|
||||
it("should return current path unchanged when nodePath is 'node'", () => {
|
||||
const currentPath = '/usr/bin:/usr/local/bin';
|
||||
const result = buildEnhancedPath('node', currentPath);
|
||||
|
||||
expect(result).toBe(currentPath);
|
||||
});
|
||||
|
||||
it("should return empty string when nodePath is 'node' and currentPath is empty", () => {
|
||||
const result = buildEnhancedPath('node', '');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should prepend node directory to path', () => {
|
||||
const nodePath = '/opt/homebrew/bin/node';
|
||||
const currentPath = '/usr/bin:/usr/local/bin';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, currentPath);
|
||||
|
||||
expect(result).toBe(`/opt/homebrew/bin${delimiter}${currentPath}`);
|
||||
});
|
||||
|
||||
it('should not duplicate node directory if already in path', () => {
|
||||
const nodePath = '/usr/local/bin/node';
|
||||
const currentPath = '/usr/local/bin:/usr/bin';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, currentPath);
|
||||
|
||||
expect(result).toBe(currentPath);
|
||||
});
|
||||
|
||||
it('should handle empty currentPath without trailing delimiter', () => {
|
||||
const nodePath = '/opt/homebrew/bin/node';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, '');
|
||||
|
||||
expect(result).toBe('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('should handle Windows-style paths', () => {
|
||||
// On Windows, path.dirname recognizes backslash paths
|
||||
// On other platforms, backslash is not a path separator
|
||||
const nodePath = 'C:\\Program Files\\nodejs\\node.exe';
|
||||
const currentPath = 'C:\\Windows\\System32';
|
||||
|
||||
const result = buildEnhancedPath(nodePath, currentPath);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, should prepend the node directory
|
||||
expect(result).toBe(`C:\\Program Files\\nodejs${delimiter}${currentPath}`);
|
||||
} else {
|
||||
// On non-Windows, backslash paths are treated as relative paths
|
||||
// path.dirname returns '.' so the function returns currentPath unchanged
|
||||
expect(result).toBe(currentPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use default empty string for currentPath', () => {
|
||||
const nodePath = '/usr/local/bin/node';
|
||||
|
||||
const result = buildEnhancedPath(nodePath);
|
||||
|
||||
expect(result).toBe('/usr/local/bin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,6 @@ export interface Feature {
|
||||
titleGenerating?: boolean;
|
||||
category: string;
|
||||
description: string;
|
||||
steps?: string[];
|
||||
passes?: boolean;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
|
||||
Reference in New Issue
Block a user