Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cba050243e | |||
| baf6e4bb35 | |||
| 76fdbade6f | |||
| 979d97757f | |||
| 4c832e6a35 | |||
| f6bf6d4ada | |||
| 18453adb1d | |||
| 3f8e16edb2 | |||
| 53b55468c9 | |||
| 1984a62d9b | |||
| a5670a6912 | |||
| d958fa65cb | |||
| bec3fc88a2 | |||
| 1308ec1433 | |||
| d5c8949173 |
@@ -80,7 +80,7 @@ Output: Raw JSON status data
|
||||
|
||||
## Status File
|
||||
|
||||
Auto-build writes status to `.auto-claude-status` in your project root:
|
||||
Auto-build writes status to `.aperant-status` in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -121,7 +121,7 @@ When active, you'll see these indicators:
|
||||
## Troubleshooting
|
||||
|
||||
### Status not showing?
|
||||
1. Check if `.auto-claude-status` exists in your project root
|
||||
1. Check if `.aperant-status` exists in your project root
|
||||
2. Verify the path to `statusline.py` is correct
|
||||
3. Try running the command manually: `python auto-claude/statusline.py --format compact`
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
|
||||
sudo apt-get install -y flatpak flatpak-builder squashfs-tools
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
@@ -34,6 +34,7 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -61,9 +62,35 @@ jobs:
|
||||
working-directory: apps/desktop
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run unit tests
|
||||
- name: Run unit tests with coverage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
working-directory: apps/desktop
|
||||
run: npm run test
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Run unit tests
|
||||
if: matrix.os != 'ubuntu-latest'
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Upload coverage report
|
||||
if: matrix.os == 'ubuntu-latest' && always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: apps/desktop/coverage/
|
||||
retention-days: 14
|
||||
|
||||
- name: Coverage PR comment
|
||||
if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
working-directory: apps/desktop
|
||||
json-summary-path: coverage/coverage-summary.json
|
||||
json-final-path: coverage/coverage-final.json
|
||||
|
||||
- name: Build application
|
||||
working-directory: apps/desktop
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# E2E Tests
|
||||
#
|
||||
# Runs Playwright E2E tests for the Electron desktop app on Linux.
|
||||
# Ubuntu-only since Electron E2E is platform-agnostic (Chromium renderer).
|
||||
# Non-blocking initially — separate from ci-complete gate while stabilizing.
|
||||
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js frontend
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: apps/desktop
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build application
|
||||
working-directory: apps/desktop
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: apps/desktop
|
||||
continue-on-error: true # Non-blocking while stabilizing — pre-existing __dirname ESM issue
|
||||
run: xvfb-run --auto-servernum npm run test:e2e
|
||||
|
||||
- name: Upload E2E report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-report
|
||||
path: |
|
||||
apps/desktop/e2e/playwright-report/
|
||||
apps/desktop/e2e/test-results/
|
||||
retention-days: 14
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
- name: Setup Flatpak and verification tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
|
||||
sudo apt-get install -y flatpak flatpak-builder squashfs-tools
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
@@ -572,9 +572,9 @@ jobs:
|
||||
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
python3 scripts/update-readme.py "$VERSION" --prerelease
|
||||
node scripts/update-readme.mjs "$VERSION" --prerelease
|
||||
else
|
||||
python3 scripts/update-readme.py "$VERSION"
|
||||
node scripts/update-readme.mjs "$VERSION"
|
||||
fi
|
||||
|
||||
echo "--- Verifying update ---"
|
||||
|
||||
@@ -54,13 +54,16 @@ lerna-debug.log*
|
||||
.worktrees/
|
||||
|
||||
# ===========================
|
||||
# Auto Claude Generated
|
||||
# Aperant Generated
|
||||
# ===========================
|
||||
.aperant/
|
||||
.auto-claude/
|
||||
.planning/
|
||||
.planning-archive/
|
||||
.auto-build-security.json
|
||||
.aperant-security.json
|
||||
.auto-claude-security.json
|
||||
.aperant-status
|
||||
.auto-claude-status
|
||||
.claude_settings.json
|
||||
.update-metadata.json
|
||||
|
||||
@@ -82,10 +82,10 @@ Your context window will be automatically compacted as it approaches its limit,
|
||||
|
||||
### Resetting PR Review State
|
||||
|
||||
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.auto-claude/github/`:
|
||||
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.aperant/github/`:
|
||||
|
||||
1. `rm .auto-claude/github/pr/logs_*.json` — review log files
|
||||
2. `rm .auto-claude/github/pr/review_*.json` — review result files
|
||||
1. `rm .aperant/github/pr/logs_*.json` — review log files
|
||||
2. `rm .aperant/github/pr/review_*.json` — review result files
|
||||
3. Reset `pr/index.json` to `{"reviews": [], "last_updated": null}`
|
||||
4. Reset `bot_detection_state.json` to `{"reviewed_commits": {}}` — this is the gatekeeper; without clearing it, the bot detector skips already-seen commits
|
||||
|
||||
@@ -217,7 +217,7 @@ const readTool = tool({
|
||||
|
||||
### Spec Directory Structure
|
||||
|
||||
Each spec in `.auto-claude/specs/XXX-name/` contains: `spec.md`, `requirements.json`, `context.json`, `implementation_plan.json`, `qa_report.md`, `QA_FIX_REQUEST.md`
|
||||
Each spec in `.aperant/specs/XXX-name/` contains: `spec.md`, `requirements.json`, `context.json`, `implementation_plan.json`, `qa_report.md`, `QA_FIX_REQUEST.md`
|
||||
|
||||
### Memory System (Graphiti)
|
||||
|
||||
@@ -355,5 +355,5 @@ npm run dev # Development mode with HMR
|
||||
npm run dev:debug # Debug mode with verbose output
|
||||
npm run dev:mcp # Electron MCP server for AI debugging
|
||||
|
||||
# Project data: .auto-claude/specs/ (gitignored)
|
||||
# Project data: .aperant/specs/ (gitignored)
|
||||
```
|
||||
|
||||
@@ -36,18 +36,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.1)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.5)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.8.0-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.8.0-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.8.0-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.8.0-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.8.0-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.8.0-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -231,9 +231,9 @@ test.describe('E2E Flow Verification (Mock-based)', () => {
|
||||
const projectPath = TEST_PROJECT_DIR;
|
||||
expect(existsSync(projectPath)).toBe(true);
|
||||
|
||||
// Check for auto-claude directory detection
|
||||
const autoBuildPath = path.join(projectPath, 'auto-claude');
|
||||
expect(existsSync(autoBuildPath)).toBe(true);
|
||||
// Check for aperant directory detection
|
||||
const aperantPath = path.join(projectPath, '.aperant');
|
||||
expect(existsSync(aperantPath)).toBe(true);
|
||||
|
||||
cleanupTestEnvironment();
|
||||
});
|
||||
|
||||
@@ -78,7 +78,9 @@ export default defineConfig({
|
||||
// spawned via `new Worker(path)` from WorkerBridge
|
||||
'ai/agent/worker': resolve(__dirname, 'src/main/ai/agent/worker.ts'),
|
||||
},
|
||||
// Only node-pty needs to be external (native module rebuilt by electron-builder)
|
||||
// Native modules that must remain external (loaded from disk, not bundled).
|
||||
// @libsql/client is loaded lazily via globalThis.require() and resolved
|
||||
// from extraResources/node_modules via Module.globalPaths (see index.ts).
|
||||
external: ['@lydell/node-pty']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"name": "aperant",
|
||||
"version": "2.8.0-beta.1",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
"description": "Autonomous multi-agent coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Aperant",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/AndyMik90/Auto-Claude.git"
|
||||
"url": "https://github.com/AndyMik90/Aperant.git"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
"name": "Auto Claude Team",
|
||||
"name": "Aperant Team",
|
||||
"email": "119136210+AndyMik90@users.noreply.github.com"
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
@@ -35,10 +35,12 @@
|
||||
"package:flatpak": "electron-builder --linux flatpak",
|
||||
"verify:linux": "node scripts/verify-linux-packages.cjs dist",
|
||||
"test:verify-linux": "node --test scripts/verify-linux-packages.test.mjs",
|
||||
"start:packaged:mac": "open dist/mac-arm64/Auto-Claude.app || open dist/mac/Auto-Claude.app",
|
||||
"start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"",
|
||||
"start:packaged:linux": "./dist/linux-unpacked/auto-claude",
|
||||
"start:packaged:mac": "open dist/mac-arm64/Aperant.app || open dist/mac/Aperant.app",
|
||||
"start:packaged:win": "start \"\" \"dist\\win-unpacked\\Aperant.exe\"",
|
||||
"start:packaged:linux": "./dist/linux-unpacked/aperant",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --exclude src/__tests__/integration/ --exclude src/__tests__/e2e/",
|
||||
"test:integration": "vitest run src/__tests__/integration/",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "npx playwright test --config=e2e/playwright.config.ts",
|
||||
@@ -48,27 +50,27 @@
|
||||
"typecheck": "tsc --noEmit --incremental"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.61",
|
||||
"@ai-sdk/anthropic": "^3.0.45",
|
||||
"@ai-sdk/azure": "^3.0.31",
|
||||
"@ai-sdk/google": "^3.0.29",
|
||||
"@ai-sdk/groq": "^3.0.24",
|
||||
"@ai-sdk/mcp": "^1.0.21",
|
||||
"@ai-sdk/mistral": "^2.0.28",
|
||||
"@ai-sdk/openai": "^3.0.30",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@ai-sdk/xai": "^3.0.57",
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.77",
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
"@ai-sdk/azure": "^3.0.42",
|
||||
"@ai-sdk/google": "^3.0.43",
|
||||
"@ai-sdk/groq": "^3.0.29",
|
||||
"@ai-sdk/mcp": "^1.0.25",
|
||||
"@ai-sdk/mistral": "^3.0.24",
|
||||
"@ai-sdk/openai": "^3.0.41",
|
||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||
"@ai-sdk/xai": "^3.0.67",
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@lydell/node-pty": "^1.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.3.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
@@ -82,88 +84,89 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.5.0",
|
||||
"@sentry/electron": "^7.10.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@tanstack/react-virtual": "^3.13.22",
|
||||
"@tavily/core": "^0.7.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"ai": "^6.0.91",
|
||||
"ai": "^6.0.116",
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"i18next": "^25.7.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"minimatch": "^10.1.1",
|
||||
"motion": "^12.23.26",
|
||||
"electron-updater": "^6.8.3",
|
||||
"i18next": "^25.8.18",
|
||||
"lucide-react": "^0.577.0",
|
||||
"minimatch": "^10.2.4",
|
||||
"motion": "^12.36.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"semver": "^7.7.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"uuid": "^13.0.0",
|
||||
"web-tree-sitter": "^0.26.5",
|
||||
"xstate": "^5.26.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
"web-tree-sitter": "^0.26.7",
|
||||
"xstate": "^5.28.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@biomejs/biome": "2.4.7",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/minimatch": "^6.0.0",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "40.0.0",
|
||||
"electron-builder": "^26.4.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.3.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"lint-staged": "^16.4.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^4.0.16"
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"electron-builder-squirrel-windows": "^26.0.12",
|
||||
"dmg-builder": "^26.0.12",
|
||||
"@electron/rebuild": "4.0.2"
|
||||
"@electron/rebuild": "4.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.autoclaude.ui",
|
||||
"productName": "Auto-Claude",
|
||||
"appId": "com.aperant.app",
|
||||
"productName": "Aperant",
|
||||
"npmRebuild": false,
|
||||
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}",
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "AndyMik90",
|
||||
"repo": "Auto-Claude"
|
||||
"repo": "Aperant"
|
||||
}
|
||||
],
|
||||
"directories": {
|
||||
@@ -185,6 +188,22 @@
|
||||
{
|
||||
"from": "prompts",
|
||||
"to": "prompts"
|
||||
},
|
||||
{
|
||||
"from": "../../node_modules/@libsql",
|
||||
"to": "node_modules/@libsql"
|
||||
},
|
||||
{
|
||||
"from": "../../node_modules/libsql",
|
||||
"to": "node_modules/libsql"
|
||||
},
|
||||
{
|
||||
"from": "../../node_modules/@neon-rs",
|
||||
"to": "node_modules/@neon-rs"
|
||||
},
|
||||
{
|
||||
"from": "../../node_modules/detect-libc",
|
||||
"to": "node_modules/detect-libc"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
|
||||
@@ -12,7 +12,7 @@ You are continuing work on an autonomous development task. This is a **FRESH con
|
||||
environment at the start of each prompt in the "YOUR ENVIRONMENT" section. Pay close attention to:
|
||||
|
||||
- **Working Directory**: This is your root - all paths are relative to here
|
||||
- **Spec Location**: Where your spec files live (usually `./auto-claude/specs/{spec-name}/`)
|
||||
- **Spec Location**: Where your spec files live (usually `./.aperant/specs/{spec-name}/`)
|
||||
- **Isolation Mode**: If present, you are in an isolated worktree (see below)
|
||||
|
||||
**RULES:**
|
||||
@@ -57,9 +57,9 @@ You may see absolute paths like `/e/projects/myapp/prod/src/file.ts` in:
|
||||
```bash
|
||||
# Verify you're still in the worktree
|
||||
pwd
|
||||
# Should show: .../.auto-claude/worktrees/tasks/{spec-name}/
|
||||
# Should show: .../.aperant/worktrees/tasks/{spec-name}/
|
||||
# Or (legacy): .../.worktrees/{spec-name}/
|
||||
# Or (PR review): .../.auto-claude/github/pr/worktrees/{pr-number}/
|
||||
# Or (PR review): .../.aperant/github/pr/worktrees/{pr-number}/
|
||||
# NOT: /path/to/main/project
|
||||
```
|
||||
|
||||
@@ -140,7 +140,7 @@ pwd && ls -la
|
||||
find . -name "implementation_plan.json" -type f 2>/dev/null | head -5
|
||||
|
||||
# 3. Set SPEC_DIR based on what you find (example - adjust path as needed)
|
||||
SPEC_DIR="./auto-claude/specs/YOUR-SPEC-NAME" # Replace with actual path from step 2
|
||||
SPEC_DIR="./.aperant/specs/YOUR-SPEC-NAME" # Replace with actual path from step 2
|
||||
|
||||
# 4. Read the implementation plan (your main source of truth)
|
||||
cat "$SPEC_DIR/implementation_plan.json"
|
||||
@@ -791,7 +791,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
api_key = os.environ.get("API_KEY")
|
||||
```
|
||||
3. **Update .env.example** - Add placeholder for the new variable
|
||||
4. **Re-stage and retry** - `git add . ':!.auto-claude' && git commit ...`
|
||||
4. **Re-stage and retry** - `git add . ':!.aperant' && git commit ...`
|
||||
|
||||
**If it's a false positive:**
|
||||
- Add the file pattern to `.secretsignore` in the project root
|
||||
@@ -803,22 +803,22 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
# FIRST: Make sure you're in the working directory root (check YOUR ENVIRONMENT section at top)
|
||||
pwd # Should match your working directory
|
||||
|
||||
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
|
||||
git add . ':!.auto-claude'
|
||||
# Add all files EXCEPT .aperant directory (spec files should never be committed)
|
||||
git add . ':!.aperant'
|
||||
|
||||
# If git add fails with "pathspec did not match", you have a path problem:
|
||||
# 1. Run pwd to see where you are
|
||||
# 2. Run git status to see what git sees
|
||||
# 3. Adjust your paths accordingly
|
||||
|
||||
git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
|
||||
git commit -m "aperant: Complete [subtask-id] - [subtask description]
|
||||
|
||||
- Files modified: [list]
|
||||
- Verification: [type] - passed
|
||||
- Phase progress: [X]/[Y] subtasks complete"
|
||||
```
|
||||
|
||||
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
|
||||
**CRITICAL**: The `:!.aperant` pathspec exclusion ensures spec files are NEVER committed.
|
||||
These are internal tracking files that must stay local.
|
||||
|
||||
### DO NOT Push to Remote
|
||||
@@ -851,7 +851,7 @@ Next phase (if applicable): [phase-name]
|
||||
=== END SESSION N ===
|
||||
```
|
||||
|
||||
**Note:** The `build-progress.txt` file is in `.auto-claude/specs/` which is gitignored.
|
||||
**Note:** The `build-progress.txt` file is in `.aperant/specs/` which is gitignored.
|
||||
Do NOT try to commit it - the framework tracks progress automatically.
|
||||
|
||||
---
|
||||
@@ -881,7 +881,7 @@ All subtasks completed!
|
||||
Workflow type: [type]
|
||||
Total phases: [N]
|
||||
Total subtasks: [N]
|
||||
Branch: auto-claude/[feature-name]
|
||||
Branch: aperant/[feature-name]
|
||||
|
||||
Ready for human review and merge.
|
||||
```
|
||||
|
||||
@@ -741,7 +741,7 @@ The following files are gitignored and should NOT be committed:
|
||||
- `init.sh` - tracked locally only
|
||||
- `build-progress.txt` - tracked locally only
|
||||
|
||||
These files live in `.auto-claude/specs/` which is gitignored. The orchestrator handles syncing them between worktrees and the main project.
|
||||
These files live in `.aperant/specs/` which is gitignored. The orchestrator handles syncing them between worktrees and the main project.
|
||||
|
||||
**Only code changes should be committed** - spec metadata stays local.
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ You are the **QA Fix Agent** in an autonomous development process. The QA Review
|
||||
### NEVER edit qa_report.md
|
||||
The `qa_report.md` file belongs to the QA Reviewer. You must NEVER modify it. The reviewer writes the verdict; you implement fixes. If you change the report status (e.g., to "FIXES_APPLIED"), the orchestrator won't recognize it as a valid verdict and your fixes will be wasted.
|
||||
|
||||
### Fix in the PROJECT SOURCE, not in .auto-claude/specs/
|
||||
All your code changes, documentation additions, and new files must go into the **project source tree** (the actual codebase). Never create deliverable files inside `.auto-claude/specs/` — that directory contains gitignored metadata (spec, plan, QA report). The QA reviewer evaluates the project source, not spec artifacts.
|
||||
### Fix in the PROJECT SOURCE, not in .aperant/specs/
|
||||
All your code changes, documentation additions, and new files must go into the **project source tree** (the actual codebase). Never create deliverable files inside `.aperant/specs/` — that directory contains gitignored metadata (spec, plan, QA report). The QA reviewer evaluates the project source, not spec artifacts.
|
||||
|
||||
**Example:** If QA says "missing route inventory document", create it in the project root (e.g., `docs/route-policy.md` or `ROUTE_POLICY.md`), NOT in `.auto-claude/specs/route_access_policy.md`.
|
||||
**Example:** If QA says "missing route inventory document", create it in the project root (e.g., `docs/route-policy.md` or `ROUTE_POLICY.md`), NOT in `.aperant/specs/route_access_policy.md`.
|
||||
|
||||
### Fix CODE issues with CODE, not documentation
|
||||
If QA reports a missing test, write the test. If QA reports a code bug, fix the code. Don't write a markdown document explaining why the code is fine — write the code that makes it fine.
|
||||
@@ -204,7 +204,7 @@ Escaping the worktree causes:
|
||||
pwd
|
||||
|
||||
# 2. Verify the target is within your worktree
|
||||
# If pwd shows: /path/to/.auto-claude/worktrees/tasks/spec-name/
|
||||
# If pwd shows: /path/to/.aperant/worktrees/tasks/spec-name/
|
||||
# Then: cd ./apps/desktop ✅ SAFE
|
||||
# But: cd /path/to/parent/project ❌ FORBIDDEN - ESCAPES ISOLATION
|
||||
|
||||
@@ -338,8 +338,8 @@ ls -la [path-to-files] # Make sure the path is correct from your current locati
|
||||
# FIRST: Make sure you're in the working directory root
|
||||
pwd # Should match your working directory
|
||||
|
||||
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
|
||||
git add . ':!.auto-claude'
|
||||
# Add all files EXCEPT .aperant directory (spec files should never be committed)
|
||||
git add . ':!.aperant'
|
||||
|
||||
# If git add fails with "pathspec did not match", you have a path problem:
|
||||
# 1. Run pwd to see where you are
|
||||
@@ -360,7 +360,7 @@ Verified:
|
||||
QA Fix Session: [N]"
|
||||
```
|
||||
|
||||
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
|
||||
**CRITICAL**: The `:!.aperant` pathspec exclusion ensures spec files are NEVER committed.
|
||||
|
||||
**NOTE**: Do NOT push to remote. All work stays local until user reviews and approves.
|
||||
|
||||
@@ -490,7 +490,7 @@ npx prisma migrate dev --name [name]
|
||||
|
||||
### Write Deliverables to the Project, Not Spec Artifacts
|
||||
- All new files (docs, tests, code) go in the project source tree
|
||||
- NEVER create deliverable files in `.auto-claude/specs/` — that directory is gitignored metadata
|
||||
- NEVER create deliverable files in `.aperant/specs/` — that directory is gitignored metadata
|
||||
|
||||
### Git Configuration - NEVER MODIFY
|
||||
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
|
||||
|
||||
@@ -480,7 +480,7 @@ cat > qa_report.md << 'EOF'
|
||||
[QA Report content]
|
||||
EOF
|
||||
|
||||
# Note: qa_report.md and implementation_plan.json are in .auto-claude/specs/ (gitignored)
|
||||
# Note: qa_report.md and implementation_plan.json are in .aperant/specs/ (gitignored)
|
||||
# Do NOT commit them - the framework tracks QA status automatically
|
||||
# Only commit actual code changes to the project
|
||||
```
|
||||
@@ -517,7 +517,7 @@ Once fixes are complete:
|
||||
|
||||
EOF
|
||||
|
||||
# Note: QA_FIX_REQUEST.md and implementation_plan.json are in .auto-claude/specs/ (gitignored)
|
||||
# Note: QA_FIX_REQUEST.md and implementation_plan.json are in .aperant/specs/ (gitignored)
|
||||
# Do NOT commit them - the framework tracks QA status automatically
|
||||
# Only commit actual code fixes to the project
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 921 B After Width: | Height: | Size: 455 B |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify Linux package contents to ensure AppImage, deb, and Flatpak were built correctly.
|
||||
*
|
||||
* This script inspects each Linux package format to verify that the bundled Electron
|
||||
* application (app.asar) is present and packages are valid.
|
||||
*
|
||||
* Usage: node scripts/verify-linux-packages.cjs [dist-dir]
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Minimum expected Flatpak file size (50 MB)
|
||||
// Flatpak files are large OCI archives; anything smaller is suspicious
|
||||
const FLATPAK_MIN_SIZE_MB = 50;
|
||||
|
||||
// Colors for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(message, color = colors.reset) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`\u2713 ${message}`, colors.green);
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`\u2717 ${message}`, colors.red);
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`\u26A0 ${message}`, colors.yellow);
|
||||
}
|
||||
|
||||
function logInfo(message) {
|
||||
log(`\u2139 ${message}`, colors.cyan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command exists
|
||||
* Uses 'which' directly without shell interpolation to prevent command injection
|
||||
*/
|
||||
function commandExists(cmd) {
|
||||
const result = spawnSync('which', [cmd], { stdio: 'ignore' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all Linux packages in the dist directory
|
||||
*/
|
||||
function findPackages(distDir) {
|
||||
const packages = {
|
||||
appImage: null,
|
||||
deb: null,
|
||||
flatpak: null,
|
||||
};
|
||||
|
||||
if (!fs.existsSync(distDir)) {
|
||||
logError(`Distribution directory not found: ${distDir}`);
|
||||
return packages;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(distDir);
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(distDir, file);
|
||||
|
||||
if (file.endsWith('.AppImage')) {
|
||||
if (!packages.appImage) {
|
||||
packages.appImage = fullPath;
|
||||
} else {
|
||||
logWarning(`Multiple AppImage files found, using first: ${path.basename(packages.appImage)}`);
|
||||
}
|
||||
} else if (file.endsWith('.deb')) {
|
||||
if (!packages.deb) {
|
||||
packages.deb = fullPath;
|
||||
} else {
|
||||
logWarning(`Multiple deb files found, using first: ${path.basename(packages.deb)}`);
|
||||
}
|
||||
} else if (file.endsWith('.flatpak')) {
|
||||
if (!packages.flatpak) {
|
||||
packages.flatpak = fullPath;
|
||||
} else {
|
||||
logWarning(`Multiple Flatpak files found, using first: ${path.basename(packages.flatpak)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a file listing contains the bundled Electron app (app.asar)
|
||||
* @param {string[]} files - List of files from package
|
||||
* @param {string} packageType - Type of package (for error messages)
|
||||
* @returns {Object} Verification result with verified flag and issues array
|
||||
*/
|
||||
function verifyFileList(files, packageType) {
|
||||
const issues = [];
|
||||
|
||||
// Check for app.asar (the bundled Electron application)
|
||||
// Use boundary-safe match to avoid false positives from resources/app.asar.unpacked
|
||||
const appAsarPattern = /[\\/]resources[\\/]app\.asar$/;
|
||||
const appAsarFound = files.some((f) => appAsarPattern.test(f.trim()));
|
||||
if (!appAsarFound) {
|
||||
issues.push(`app.asar not found in ${packageType} — the Electron app bundle is missing`);
|
||||
}
|
||||
|
||||
return {
|
||||
verified: issues.length === 0,
|
||||
issues,
|
||||
fileCount: files.filter((f) => f.trim()).length,
|
||||
};
|
||||
}
|
||||
|
||||
// Minimum expected AppImage file size (50 MB)
|
||||
const APPIMAGE_MIN_SIZE_MB = 50;
|
||||
|
||||
/**
|
||||
* Verify AppImage contents.
|
||||
* AppImages are ELF executables with an embedded SquashFS filesystem.
|
||||
* We try unsquashfs first (can list SquashFS contents), then fall back
|
||||
* to the AppImage's own --appimage-extract, and finally to a size check.
|
||||
*/
|
||||
function verifyAppImage(appImagePath) {
|
||||
logInfo(`Verifying AppImage: ${path.basename(appImagePath)}`);
|
||||
|
||||
// Try unsquashfs -l (lists squashfs contents without extracting)
|
||||
if (commandExists('unsquashfs')) {
|
||||
const result = spawnSync('unsquashfs', ['-l', appImagePath], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
logWarning(`unsquashfs failed: ${result.error.message}, falling back to size check`);
|
||||
} else if (result.status !== 0) {
|
||||
logWarning(`unsquashfs could not read AppImage, falling back to size check`);
|
||||
} else {
|
||||
const files = result.stdout.split('\n');
|
||||
return verifyFileList(files, 'AppImage');
|
||||
}
|
||||
}
|
||||
|
||||
// Try self-extraction to list contents (AppImages support --appimage-extract-and-run)
|
||||
// Make the AppImage executable first
|
||||
try {
|
||||
fs.chmodSync(appImagePath, 0o755);
|
||||
} catch (_) {
|
||||
// Ignore chmod errors
|
||||
}
|
||||
|
||||
const extractResult = spawnSync(appImagePath, ['--appimage-extract', '--stdout'], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
env: { ...process.env, APPIMAGE_EXTRACT_AND_RUN: '1' },
|
||||
});
|
||||
|
||||
// --appimage-extract creates a squashfs-root directory; check if it exists
|
||||
const squashfsRoot = path.join(path.dirname(appImagePath), 'squashfs-root');
|
||||
if (fs.existsSync(squashfsRoot)) {
|
||||
try {
|
||||
const collectFiles = (dir, prefix = '') => {
|
||||
const entries = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
entries.push(rel);
|
||||
if (entry.isDirectory()) {
|
||||
entries.push(...collectFiles(path.join(dir, entry.name), rel));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
const files = collectFiles(squashfsRoot);
|
||||
const verifyResult = verifyFileList(files, 'AppImage');
|
||||
// Clean up extracted directory
|
||||
fs.rmSync(squashfsRoot, { recursive: true, force: true });
|
||||
return verifyResult;
|
||||
} catch (e) {
|
||||
logWarning(`Failed to read extracted AppImage contents: ${e.message}`);
|
||||
fs.rmSync(squashfsRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to basic size validation (same approach as Flatpak)
|
||||
logWarning('Could not inspect AppImage contents (unsquashfs not available). Using size validation.');
|
||||
const issues = [];
|
||||
const stats = fs.statSync(appImagePath);
|
||||
|
||||
if (stats.size === 0) {
|
||||
return { verified: false, issues: ['AppImage file is empty'] };
|
||||
}
|
||||
|
||||
if (stats.size < APPIMAGE_MIN_SIZE_MB * 1024 * 1024) {
|
||||
issues.push(
|
||||
`AppImage file seems too small (${(stats.size / 1024 / 1024).toFixed(2)} MB, expected at least ${APPIMAGE_MIN_SIZE_MB} MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
logInfo('AppImage passed size validation (content inspection was not possible)');
|
||||
}
|
||||
|
||||
return {
|
||||
verified: issues.length === 0,
|
||||
issues,
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify deb package contents
|
||||
*/
|
||||
function verifyDeb(debPath) {
|
||||
logInfo(`Verifying deb package: ${path.basename(debPath)}`);
|
||||
|
||||
if (!commandExists('dpkg-deb')) {
|
||||
logWarning('dpkg-deb not found. Skipping deb verification');
|
||||
return { verified: false, reason: 'dpkg-deb not available', critical: true };
|
||||
}
|
||||
|
||||
const result = spawnSync('dpkg-deb', ['-c', debPath], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
logError(`Failed to execute dpkg-deb: ${result.error.message}`);
|
||||
return { verified: false, issues: [`Command execution failed: ${result.error.message}`] };
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
logError(`Failed to read deb package: ${result.stderr}`);
|
||||
return { verified: false, issues: ['Failed to extract file list'] };
|
||||
}
|
||||
|
||||
const files = result.stdout.split('\n');
|
||||
return verifyFileList(files, 'deb package');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Flatpak package contents
|
||||
* Flatpak OCI archives are complex to inspect, so we do basic validation
|
||||
*/
|
||||
function verifyFlatpak(flatpakPath) {
|
||||
logInfo(`Verifying Flatpak package: ${path.basename(flatpakPath)}`);
|
||||
|
||||
const issues = [];
|
||||
|
||||
if (!fs.existsSync(flatpakPath)) {
|
||||
return { verified: false, issues: ['Flatpak file does not exist'] };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(flatpakPath);
|
||||
if (stats.size === 0) {
|
||||
return { verified: false, issues: ['Flatpak file is empty'] };
|
||||
}
|
||||
|
||||
if (stats.size < FLATPAK_MIN_SIZE_MB * 1024 * 1024) {
|
||||
issues.push(
|
||||
`Flatpak file seems too small (${(stats.size / 1024 / 1024).toFixed(2)} MB, expected at least ${FLATPAK_MIN_SIZE_MB} MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
verified: issues.length === 0,
|
||||
issues,
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main verification function
|
||||
*/
|
||||
function main() {
|
||||
const distDir = process.argv[2] || path.join(__dirname, '..', 'dist');
|
||||
|
||||
log('\n=== Linux Package Verification ===\n', colors.blue);
|
||||
logInfo(`Distribution directory: ${distDir}\n`);
|
||||
|
||||
const packages = findPackages(distDir);
|
||||
|
||||
// Report found packages — all three targets are required
|
||||
let missingTargets = false;
|
||||
|
||||
if (packages.appImage) {
|
||||
logSuccess(`Found AppImage: ${path.basename(packages.appImage)}`);
|
||||
} else {
|
||||
logError('No AppImage found — expected build target is missing');
|
||||
missingTargets = true;
|
||||
}
|
||||
|
||||
if (packages.deb) {
|
||||
logSuccess(`Found deb: ${path.basename(packages.deb)}`);
|
||||
} else {
|
||||
logError('No deb package found — expected build target is missing');
|
||||
missingTargets = true;
|
||||
}
|
||||
|
||||
if (packages.flatpak) {
|
||||
logSuccess(`Found Flatpak: ${path.basename(packages.flatpak)}`);
|
||||
} else {
|
||||
logError('No Flatpak package found — expected build target is missing');
|
||||
missingTargets = true;
|
||||
}
|
||||
|
||||
if (missingTargets) {
|
||||
logError('\nOne or more expected Linux package targets are missing!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log('');
|
||||
|
||||
// Verify each package
|
||||
const results = {};
|
||||
|
||||
if (packages.appImage) {
|
||||
results.appImage = verifyAppImage(packages.appImage);
|
||||
}
|
||||
|
||||
if (packages.deb) {
|
||||
results.deb = verifyDeb(packages.deb);
|
||||
}
|
||||
|
||||
if (packages.flatpak) {
|
||||
results.flatpak = verifyFlatpak(packages.flatpak);
|
||||
}
|
||||
|
||||
// Print results
|
||||
log('\n=== Verification Results ===\n', colors.blue);
|
||||
|
||||
let hasFailures = false;
|
||||
let hasCriticalSkips = false;
|
||||
|
||||
for (const [type, result] of Object.entries(results)) {
|
||||
if (result.reason) {
|
||||
if (result.critical) {
|
||||
logError(`${type}: CRITICAL - SKIPPED (${result.reason})`);
|
||||
hasCriticalSkips = true;
|
||||
} else {
|
||||
logWarning(`${type}: SKIPPED (${result.reason})`);
|
||||
}
|
||||
} else if (result.verified) {
|
||||
logSuccess(`${type}: VERIFIED`);
|
||||
if (result.fileCount) {
|
||||
logInfo(` Files: ${result.fileCount}`);
|
||||
}
|
||||
if (result.size) {
|
||||
logInfo(` Size: ${(result.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
}
|
||||
} else {
|
||||
logError(`${type}: FAILED`);
|
||||
hasFailures = true;
|
||||
for (const issue of result.issues || []) {
|
||||
logError(` - ${issue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log('');
|
||||
|
||||
if (hasFailures || hasCriticalSkips) {
|
||||
logError('\n=== VERIFICATION FAILED ===\n');
|
||||
if (hasFailures) {
|
||||
log('Some packages are missing critical files. This will cause runtime errors.\n', colors.red);
|
||||
}
|
||||
if (hasCriticalSkips) {
|
||||
log('Some packages could not be verified due to missing required tools.\n', colors.red);
|
||||
log('Install required tools:\n', colors.red);
|
||||
log(' - unsquashfs: sudo apt-get install squashfs-tools\n', colors.red);
|
||||
log(' - dpkg-deb: sudo apt-get install dpkg\n', colors.red);
|
||||
}
|
||||
process.exit(1);
|
||||
} else {
|
||||
logSuccess('\n=== ALL PACKAGES VERIFIED ===\n');
|
||||
log('All Linux packages contain the required files.\n', colors.green);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run main if this file is executed directly (not imported)
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
module.exports = {
|
||||
findPackages,
|
||||
verifyFileList,
|
||||
verifyAppImage,
|
||||
verifyDeb,
|
||||
verifyFlatpak,
|
||||
};
|
||||
@@ -127,7 +127,7 @@ function setupTestDirs(): void {
|
||||
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
|
||||
// Create a minimal project structure
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude'), { recursive: true });
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, '.aperant'), { recursive: true });
|
||||
}
|
||||
|
||||
// Cleanup test directories
|
||||
|
||||
@@ -23,7 +23,7 @@ let PLAN_PATH: string;
|
||||
// Setup test directories
|
||||
function setupTestDirs(): void {
|
||||
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'rate-limit-recovery-test-'));
|
||||
TEST_SPEC_DIR = path.join(TEST_DIR, '.auto-claude/specs/001-test-feature');
|
||||
TEST_SPEC_DIR = path.join(TEST_DIR, '.aperant/specs/001-test-feature');
|
||||
PLAN_PATH = path.join(TEST_SPEC_DIR, 'implementation_plan.json');
|
||||
mkdirSync(TEST_SPEC_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ function setupTestDirs(): void {
|
||||
// Create secure temp directory with random suffix
|
||||
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'task-lifecycle-test-'));
|
||||
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||
TEST_SPEC_DIR = path.join(TEST_PROJECT_PATH, '.auto-claude/specs/001-test-feature');
|
||||
TEST_SPEC_DIR = path.join(TEST_PROJECT_PATH, '.aperant/specs/001-test-feature');
|
||||
mkdirSync(TEST_SPEC_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ if (typeof global.requestAnimationFrame === 'undefined') {
|
||||
}
|
||||
|
||||
// Test data directory for isolated file operations
|
||||
export const TEST_DATA_DIR = '/tmp/auto-claude-ui-tests';
|
||||
export const TEST_DATA_DIR = '/tmp/aperant-ui-tests';
|
||||
|
||||
// Create fresh test directory before each test
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('Application Logger', () => {
|
||||
const { generateDebugReport } = await import('../app-logger');
|
||||
const report = generateDebugReport();
|
||||
|
||||
expect(report).toContain('=== Auto Claude Debug Report ===');
|
||||
expect(report).toContain('=== Aperant Debug Report ===');
|
||||
expect(report).toContain('--- System Information ---');
|
||||
expect(report).toContain('--- Recent Errors ---');
|
||||
expect(report).toContain('=== End Debug Report ===');
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Tests for ensureOnboardingComplete function in cli-integration-handler.ts
|
||||
*
|
||||
* Tests the exported ensureOnboardingComplete() which reads/writes .claude.json
|
||||
* to set hasCompletedOnboarding: true, suppressing Claude's onboarding wizard
|
||||
* for already-authenticated profiles.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// ---- fs mock (sync only — the function uses fs, not fs/promises) ----
|
||||
const mockFiles: Map<string, string | Error> = new Map();
|
||||
|
||||
vi.mock('fs', () => {
|
||||
const readFileSync = vi.fn((filePath: string, _encoding?: string): string => {
|
||||
const entry = mockFiles.get(filePath);
|
||||
if (entry === undefined) {
|
||||
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
if (entry instanceof Error) {
|
||||
throw entry;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
const writeFileSync = vi.fn();
|
||||
const renameSync = vi.fn();
|
||||
|
||||
return { default: { readFileSync, writeFileSync, renameSync }, readFileSync, writeFileSync, renameSync };
|
||||
});
|
||||
|
||||
// ---- stubs for heavy transitive dependencies ----
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: vi.fn() },
|
||||
app: { getPath: vi.fn(() => os.tmpdir()), getAppPath: vi.fn(() => os.tmpdir()) },
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } }));
|
||||
|
||||
vi.mock('../../shared/constants', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../shared/constants')>('../../shared/constants');
|
||||
return { ...actual };
|
||||
});
|
||||
|
||||
vi.mock('../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: vi.fn(),
|
||||
initializeClaudeProfileManager: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../claude-profile/credential-utils', () => ({
|
||||
getFullCredentialsFromKeychain: vi.fn(),
|
||||
clearKeychainCache: vi.fn(),
|
||||
updateProfileSubscriptionMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../claude-profile/usage-monitor', () => ({
|
||||
getUsageMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../claude-profile/profile-utils', () => ({
|
||||
getEmailFromConfigDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../terminal/output-parser', () => ({}));
|
||||
vi.mock('../terminal/session-handler', () => ({}));
|
||||
|
||||
vi.mock('./pty-manager', () => ({
|
||||
writeToPty: vi.fn(),
|
||||
resizePty: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../ipc-handlers/utils', () => ({
|
||||
safeSendToRenderer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils/debug-logger', () => ({
|
||||
debugLog: vi.fn(),
|
||||
debugError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils/shell-escape', () => ({
|
||||
escapeShellArg: vi.fn((s: string) => s),
|
||||
escapeForWindowsDoubleQuote: vi.fn((s: string) => s),
|
||||
buildCdCommand: vi.fn((cwd: string) => `cd ${cwd}`),
|
||||
}));
|
||||
|
||||
vi.mock('../cli-utils', () => ({
|
||||
getClaudeCliInvocation: vi.fn(() => 'claude'),
|
||||
getClaudeCliInvocationAsync: vi.fn(async () => 'claude'),
|
||||
}));
|
||||
|
||||
vi.mock('../platform', () => ({
|
||||
isWindows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../settings-utils', () => ({
|
||||
readSettingsFileAsync: vi.fn(async () => ({})),
|
||||
readSettingsFile: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// ---- import the function under test ----
|
||||
import { ensureOnboardingComplete } from '../terminal/cli-integration-handler';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// ---- helpers ----
|
||||
function claudeJsonPath(configDir: string): string {
|
||||
const expanded = configDir.startsWith('~')
|
||||
? configDir.replace(/^~/, os.homedir())
|
||||
: configDir;
|
||||
return path.join(path.resolve(expanded), '.claude.json');
|
||||
}
|
||||
|
||||
const TEST_DIR = '/tmp/test-profile';
|
||||
|
||||
describe('ensureOnboardingComplete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFiles.clear();
|
||||
});
|
||||
|
||||
// ---- ENOENT: file does not exist ----
|
||||
test('returns early (no write) when .claude.json does not exist', () => {
|
||||
// mockFiles is empty → readFileSync will throw ENOENT
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- already set ----
|
||||
test('returns early (no write) when hasCompletedOnboarding is already true', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({ hasCompletedOnboarding: true }));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- missing flag → should write ----
|
||||
test('writes hasCompletedOnboarding: true when flag is absent', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({ someOtherField: 'value' }));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledOnce();
|
||||
const written = JSON.parse((fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
|
||||
expect(written.hasCompletedOnboarding).toBe(true);
|
||||
expect(written.someOtherField).toBe('value');
|
||||
});
|
||||
|
||||
// ---- flag is false → should write ----
|
||||
test('writes hasCompletedOnboarding: true when flag is false', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({ hasCompletedOnboarding: false }));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledOnce();
|
||||
const written = JSON.parse((fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
|
||||
expect(written.hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
// ---- non-object JSON (string) → should return silently ----
|
||||
test('returns early (no write) when .claude.json contains a JSON string', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify('just a string'));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- array JSON → should return silently ----
|
||||
test('returns early (no write) when .claude.json contains a JSON array', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify([1, 2, 3]));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- corrupted / invalid JSON → outer catch swallows error ----
|
||||
test('handles corrupted JSON gracefully without throwing', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, '{ invalid json }');
|
||||
|
||||
expect(() => ensureOnboardingComplete(TEST_DIR)).not.toThrow();
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- tilde expansion ----
|
||||
test('expands leading tilde to home directory', () => {
|
||||
const tildeDir = '~/myprofile';
|
||||
const resolvedDir = path.resolve(tildeDir.replace(/^~/, os.homedir()));
|
||||
const filePath = path.join(resolvedDir, '.claude.json');
|
||||
|
||||
mockFiles.set(filePath, JSON.stringify({}));
|
||||
|
||||
ensureOnboardingComplete(tildeDir);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledOnce();
|
||||
// Writes to a temp file (claudeJsonPath + UUID + .tmp), then renames to target
|
||||
const writtenPath = (fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
expect(writtenPath).toMatch(new RegExp(`^${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\..*\\.tmp$`));
|
||||
expect(fs.renameSync).toHaveBeenCalledWith(writtenPath, filePath);
|
||||
});
|
||||
|
||||
// ---- write error → outer catch swallows error ----
|
||||
test('handles write error gracefully without throwing', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({}));
|
||||
|
||||
(fs.writeFileSync as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
});
|
||||
|
||||
expect(() => ensureOnboardingComplete(TEST_DIR)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe('FileWatcher concurrency', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
describe('deduplication: second watch() with same specDir is a no-op', () => {
|
||||
it('should only create one FSWatcher when watch() is called twice with the same specDir while the first is still in-flight', async () => {
|
||||
const specDir = '/project/.auto-claude/specs/001-task';
|
||||
const specDir = '/project/.aperant/specs/001-task';
|
||||
const taskId = 'task-1';
|
||||
|
||||
// To create a real async gap we need an existing watcher whose close() is slow.
|
||||
@@ -111,8 +111,8 @@ describe('FileWatcher concurrency', () => {
|
||||
describe('supersession: watch() with different specDir replaces the in-flight call', () => {
|
||||
it('should let the second call win when the first is awaiting close()', async () => {
|
||||
const taskId = 'task-2';
|
||||
const specDir1 = path.join('/project', '.auto-claude', 'specs', '001-first');
|
||||
const specDir2 = path.join('/project', '.auto-claude', 'specs', '002-second');
|
||||
const specDir1 = path.join('/project', '.aperant', 'specs', '001-first');
|
||||
const specDir2 = path.join('/project', '.aperant', 'specs', '002-second');
|
||||
|
||||
// First call installs an existing watcher (simulate: the watcher for
|
||||
// specDir1 is already set up so the second watch() needs to close it).
|
||||
@@ -148,8 +148,8 @@ describe('FileWatcher concurrency', () => {
|
||||
|
||||
it('first watch() bails when pendingWatches changes to a different specDir', async () => {
|
||||
const taskId = 'task-super';
|
||||
const specDir1 = path.join('/project', '.auto-claude', 'specs', 'super-first');
|
||||
const specDir2 = path.join('/project', '.auto-claude', 'specs', 'super-second');
|
||||
const specDir1 = path.join('/project', '.aperant', 'specs', 'super-first');
|
||||
const specDir2 = path.join('/project', '.aperant', 'specs', 'super-second');
|
||||
|
||||
// Make the first watcher's close() slow so we can interleave.
|
||||
let resolveFirstClose!: () => void;
|
||||
@@ -187,7 +187,7 @@ describe('FileWatcher concurrency', () => {
|
||||
describe('cancellation: unwatch() during in-flight watch() prevents watcher creation', () => {
|
||||
it('should not create a watcher when unwatch() is called before the async gap resolves', async () => {
|
||||
const taskId = 'task-3';
|
||||
const specDir = '/project/.auto-claude/specs/003-cancel';
|
||||
const specDir = '/project/.aperant/specs/003-cancel';
|
||||
|
||||
// There's no pre-existing watcher, so watch() won't call close(). But it
|
||||
// does go async (chokidar.watch is sync but we can test the cancellation
|
||||
@@ -207,7 +207,7 @@ describe('FileWatcher concurrency', () => {
|
||||
);
|
||||
|
||||
// Start a second watch() — it will await the slow close().
|
||||
const specDir2 = '/project/.auto-claude/specs/003-cancel-v2';
|
||||
const specDir2 = '/project/.aperant/specs/003-cancel-v2';
|
||||
const watchPromise = fw.watch(taskId, specDir2);
|
||||
|
||||
// While watch() is in-flight, call unwatch().
|
||||
@@ -231,8 +231,8 @@ describe('FileWatcher concurrency', () => {
|
||||
it('should cancel pending watch() calls and clear pendingWatches', async () => {
|
||||
const taskId1 = 'task-4a';
|
||||
const taskId2 = 'task-4b';
|
||||
const specDir1 = '/project/.auto-claude/specs/004a';
|
||||
const specDir2 = '/project/.auto-claude/specs/004b';
|
||||
const specDir1 = '/project/.aperant/specs/004a';
|
||||
const specDir2 = '/project/.aperant/specs/004b';
|
||||
|
||||
// Set up slow-close scenario for taskId1 (so watch() is in-flight).
|
||||
await fw.watch(taskId1, specDir1);
|
||||
@@ -243,7 +243,7 @@ describe('FileWatcher concurrency', () => {
|
||||
);
|
||||
|
||||
// Start a new watch for taskId1 with a different specDir — this is now in-flight.
|
||||
const newSpecDir1 = '/project/.auto-claude/specs/004a-v2';
|
||||
const newSpecDir1 = '/project/.aperant/specs/004a-v2';
|
||||
const watchPromise1 = fw.watch(taskId1, newSpecDir1);
|
||||
|
||||
// Start a fresh watch for taskId2.
|
||||
@@ -262,7 +262,7 @@ describe('FileWatcher concurrency', () => {
|
||||
|
||||
// pendingWatches should be cleared (we verify indirectly: a fresh
|
||||
// watch() call for taskId1 must succeed without treating it as a duplicate).
|
||||
const specDirFresh = path.join('/project', '.auto-claude', 'specs', '004a-fresh');
|
||||
const specDirFresh = path.join('/project', '.aperant', 'specs', '004a-fresh');
|
||||
await fw.watch(taskId1, specDirFresh);
|
||||
expect(fw.isWatching(taskId1)).toBe(true);
|
||||
expect(fw.getWatchedSpecDir(taskId1)).toBe(specDirFresh);
|
||||
@@ -275,7 +275,7 @@ describe('FileWatcher concurrency', () => {
|
||||
describe('getWatchedSpecDir()', () => {
|
||||
it('returns the specDir that was passed to watch()', async () => {
|
||||
const taskId = 'task-5';
|
||||
const specDir = path.join('/project', '.auto-claude', 'specs', '005-specdir');
|
||||
const specDir = path.join('/project', '.aperant', 'specs', '005-specdir');
|
||||
|
||||
await fw.watch(taskId, specDir);
|
||||
|
||||
@@ -288,8 +288,8 @@ describe('FileWatcher concurrency', () => {
|
||||
|
||||
it('returns updated specDir after re-watch with different specDir', async () => {
|
||||
const taskId = 'task-5b';
|
||||
const specDir1 = path.join('/project', '.auto-claude', 'specs', '005b-first');
|
||||
const specDir2 = path.join('/project', '.auto-claude', 'specs', '005b-second');
|
||||
const specDir1 = path.join('/project', '.aperant', 'specs', '005b-first');
|
||||
const specDir2 = path.join('/project', '.aperant', 'specs', '005b-second');
|
||||
|
||||
await fw.watch(taskId, specDir1);
|
||||
expect(fw.getWatchedSpecDir(taskId)).toBe(specDir1);
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('InsightsConfig', () => {
|
||||
|
||||
it('should build process env with profile settings', async () => {
|
||||
const config = new InsightsConfig();
|
||||
vi.spyOn(config, 'loadAutoBuildEnv').mockReturnValue({ CUSTOM_ENV: '1' });
|
||||
vi.spyOn(config, 'loadAperantEnv').mockReturnValue({ CUSTOM_ENV: '1' });
|
||||
|
||||
const env = await config.getProcessEnv();
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ vi.mock("electron", () => {
|
||||
// Setup test project structure
|
||||
function setupTestProject(): void {
|
||||
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, "auto-claude", "specs"), { recursive: true });
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, "aperant", "specs"), { recursive: true });
|
||||
}
|
||||
|
||||
// Cleanup test directories
|
||||
@@ -419,15 +419,15 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
|
||||
() => mockMainWindow as never
|
||||
);
|
||||
|
||||
// Create .auto-claude directory first (before adding project so it gets detected)
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, ".auto-claude", "specs"), { recursive: true });
|
||||
// Create .aperant directory first (before adding project so it gets detected)
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, ".aperant", "specs"), { recursive: true });
|
||||
|
||||
// Add a project - it will detect .auto-claude
|
||||
// Add a project - it will detect .aperant
|
||||
const addResult = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH);
|
||||
const projectId = (addResult as { data: { id: string } }).data.id;
|
||||
|
||||
// Create a spec directory with implementation plan in .auto-claude/specs
|
||||
const specDir = path.join(TEST_PROJECT_PATH, ".auto-claude", "specs", "001-test-feature");
|
||||
// Create a spec directory with implementation plan in .aperant/specs
|
||||
const specDir = path.join(TEST_PROJECT_PATH, ".aperant", "specs", "001-test-feature");
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(specDir, "implementation_plan.json"),
|
||||
@@ -489,8 +489,8 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
|
||||
() => mockMainWindow as never
|
||||
);
|
||||
|
||||
// Create .auto-claude directory first (before adding project so it gets detected)
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, ".auto-claude", "specs"), { recursive: true });
|
||||
// Create .aperant directory first (before adding project so it gets detected)
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, ".aperant", "specs"), { recursive: true });
|
||||
|
||||
// Add a project first
|
||||
const addResult = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH);
|
||||
@@ -630,7 +630,7 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
|
||||
await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH);
|
||||
|
||||
// Create a spec/task directory with implementation_plan.json
|
||||
const specDir = path.join(TEST_PROJECT_PATH, ".auto-claude", "specs", "task-1");
|
||||
const specDir = path.join(TEST_PROJECT_PATH, ".aperant", "specs", "task-1");
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(specDir, "implementation_plan.json"),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Tests for needsMigration() and migrateProject() in project-initializer.ts
|
||||
*/
|
||||
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---- fs mock ----
|
||||
const mockExistingPaths = new Set<string>();
|
||||
const mockFiles = new Map<string, string>();
|
||||
|
||||
vi.mock('fs', () => {
|
||||
const existsSync = vi.fn((p: string) => mockExistingPaths.has(p));
|
||||
|
||||
const renameSync = vi.fn((oldPath: string, newPath: string) => {
|
||||
// Simulate rename: remove old, add new
|
||||
mockExistingPaths.delete(oldPath);
|
||||
mockExistingPaths.add(newPath);
|
||||
});
|
||||
|
||||
const readFileSync = vi.fn((filePath: string, _encoding?: string): string => {
|
||||
const content = mockFiles.get(filePath);
|
||||
if (content === undefined) {
|
||||
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
const writeFileSync = vi.fn((filePath: string, content: string) => {
|
||||
mockFiles.set(filePath, content);
|
||||
});
|
||||
|
||||
const appendFileSync = vi.fn((filePath: string, content: string) => {
|
||||
const existing = mockFiles.get(filePath) ?? '';
|
||||
mockFiles.set(filePath, existing + content);
|
||||
});
|
||||
|
||||
const mkdirSync = vi.fn();
|
||||
|
||||
return {
|
||||
default: { existsSync, renameSync, readFileSync, writeFileSync, appendFileSync, mkdirSync },
|
||||
existsSync,
|
||||
renameSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
appendFileSync,
|
||||
mkdirSync,
|
||||
};
|
||||
});
|
||||
|
||||
// ---- stub heavy transitive deps ----
|
||||
vi.mock('child_process', () => ({
|
||||
execFileSync: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
vi.mock('../cli-tool-manager', () => ({
|
||||
getToolPath: vi.fn(() => 'git'),
|
||||
}));
|
||||
|
||||
// ---- import after mocks ----
|
||||
import { needsMigration, migrateProject } from '../project-initializer';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const PROJECT = '/test/project';
|
||||
const OLD_PATH = path.join(PROJECT, '.auto-claude');
|
||||
const NEW_PATH = path.join(PROJECT, '.aperant');
|
||||
const GITIGNORE = path.join(PROJECT, '.gitignore');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExistingPaths.clear();
|
||||
mockFiles.clear();
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// needsMigration()
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('needsMigration', () => {
|
||||
test('returns true when .auto-claude exists and .aperant does not', () => {
|
||||
mockExistingPaths.add(OLD_PATH);
|
||||
expect(needsMigration(PROJECT)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when .aperant already exists', () => {
|
||||
mockExistingPaths.add(OLD_PATH);
|
||||
mockExistingPaths.add(NEW_PATH);
|
||||
expect(needsMigration(PROJECT)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when neither exists', () => {
|
||||
expect(needsMigration(PROJECT)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when both exist', () => {
|
||||
mockExistingPaths.add(OLD_PATH);
|
||||
mockExistingPaths.add(NEW_PATH);
|
||||
expect(needsMigration(PROJECT)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// migrateProject()
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('migrateProject', () => {
|
||||
test('successfully renames .auto-claude to .aperant', () => {
|
||||
mockExistingPaths.add(OLD_PATH);
|
||||
|
||||
const result = migrateProject(PROJECT);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.renameSync).toHaveBeenCalledWith(OLD_PATH, NEW_PATH);
|
||||
});
|
||||
|
||||
test('returns error when .auto-claude does not exist', () => {
|
||||
// OLD_PATH not in mockExistingPaths
|
||||
|
||||
const result = migrateProject(PROJECT);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/No \.auto-claude directory/i);
|
||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns error when .aperant already exists', () => {
|
||||
mockExistingPaths.add(OLD_PATH);
|
||||
mockExistingPaths.add(NEW_PATH);
|
||||
|
||||
const result = migrateProject(PROJECT);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/\.aperant directory already exists/i);
|
||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updates .gitignore entries during migration', () => {
|
||||
mockExistingPaths.add(OLD_PATH);
|
||||
mockFiles.set(GITIGNORE, '.auto-claude/\n.auto-claude-security.json\n.auto-claude-status\n');
|
||||
|
||||
const result = migrateProject(PROJECT);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
// Find the call that wrote to the gitignore
|
||||
const gitignoreWrite = (fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === GITIGNORE
|
||||
);
|
||||
expect(gitignoreWrite).toBeDefined();
|
||||
const writtenContent = gitignoreWrite![1] as string;
|
||||
expect(writtenContent).toContain('.aperant/');
|
||||
expect(writtenContent).not.toContain('.auto-claude/');
|
||||
});
|
||||
|
||||
test('handles .gitignore not existing gracefully', () => {
|
||||
mockExistingPaths.add(OLD_PATH);
|
||||
// mockFiles has no GITIGNORE entry → readFileSync throws ENOENT
|
||||
|
||||
// Should not throw; the catch block swallows the .gitignore read error,
|
||||
// then ensureGitignoreEntries creates the file via writeFileSync
|
||||
const result = migrateProject(PROJECT);
|
||||
expect(result.success).toBe(true);
|
||||
// ensureGitignoreEntries creates a new .gitignore with .aperant/
|
||||
const written = (fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === GITIGNORE
|
||||
);
|
||||
expect(written).toBeDefined();
|
||||
expect(written![1] as string).toContain('.aperant/');
|
||||
});
|
||||
});
|
||||
@@ -88,25 +88,25 @@ describe('ProjectStore', () => {
|
||||
expect(project1.id).toBe(project2.id);
|
||||
});
|
||||
|
||||
it('should detect auto-claude directory if present', async () => {
|
||||
// Create .auto-claude directory (the data directory, not source code)
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude'), { recursive: true });
|
||||
it('should detect aperant directory if present', async () => {
|
||||
// Create .aperant directory (the data directory, not source code)
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, '.aperant'), { recursive: true });
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
|
||||
expect(project.autoBuildPath).toBe('.auto-claude');
|
||||
expect(project.aperantPath).toBe('.aperant');
|
||||
});
|
||||
|
||||
it('should set empty autoBuildPath if not present', async () => {
|
||||
it('should set empty aperantPath if not present', async () => {
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
|
||||
expect(project.autoBuildPath).toBe('');
|
||||
expect(project.aperantPath).toBe('');
|
||||
});
|
||||
|
||||
it('should persist project to disk', async () => {
|
||||
@@ -284,8 +284,8 @@ describe('ProjectStore', () => {
|
||||
});
|
||||
|
||||
it('should read tasks from filesystem correctly', async () => {
|
||||
// Create spec directory structure in .auto-claude (the data directory)
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '001-test-feature');
|
||||
// Create spec directory structure in .aperant (the data directory)
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '001-test-feature');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -332,7 +332,7 @@ describe('ProjectStore', () => {
|
||||
});
|
||||
|
||||
it('should determine status as backlog when no subtasks completed', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '002-pending');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '002-pending');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -372,7 +372,7 @@ describe('ProjectStore', () => {
|
||||
});
|
||||
|
||||
it('should determine status as ai_review when all subtasks completed', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '003-complete');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '003-complete');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -412,7 +412,7 @@ describe('ProjectStore', () => {
|
||||
});
|
||||
|
||||
it('should determine status as human_review when plan status is human_review', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '004-rejected');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '004-rejected');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -452,7 +452,7 @@ describe('ProjectStore', () => {
|
||||
});
|
||||
|
||||
it('should determine reviewReason from plan when status is human_review', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '005-approved');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '005-approved');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -494,7 +494,7 @@ describe('ProjectStore', () => {
|
||||
|
||||
it('should determine status as done when plan status is explicitly done', async () => {
|
||||
// User explicitly marking task as done via drag-and-drop sets status to done
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '006-done');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '006-done');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -533,7 +533,7 @@ describe('ProjectStore', () => {
|
||||
});
|
||||
|
||||
it('should prefer original task description from requirements.json over plan description', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '007-description-priority');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '007-description-priority');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const aiDescription = 'AI-generated implementation plan description';
|
||||
@@ -587,7 +587,7 @@ describe('ProjectStore', () => {
|
||||
id: 'test-id-123',
|
||||
name: 'Preexisting Project',
|
||||
path: '/test/path',
|
||||
autoBuildPath: '',
|
||||
aperantPath: '',
|
||||
settings: {
|
||||
model: 'sonnet',
|
||||
memoryBackend: 'memory',
|
||||
@@ -633,7 +633,7 @@ describe('ProjectStore', () => {
|
||||
describe('archiveTasks - multi-location handling', () => {
|
||||
it('should archive task from main specs directory only', async () => {
|
||||
// Create spec directory in main location only
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '001-test-task');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '001-test-task');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -666,18 +666,18 @@ describe('ProjectStore', () => {
|
||||
|
||||
it('should archive task from BOTH main and worktree locations', async () => {
|
||||
// Create spec directory in main location
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '002-multi-location');
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '002-multi-location');
|
||||
mkdirSync(mainSpecsDir, { recursive: true });
|
||||
|
||||
// Create spec directory in worktree location
|
||||
// Worktree path: .auto-claude/worktrees/tasks/<worktreeName>/.auto-claude/specs/<taskId>
|
||||
// Worktree path: .aperant/worktrees/tasks/<worktreeName>/.aperant/specs/<taskId>
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'my-worktree',
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'specs',
|
||||
'002-multi-location'
|
||||
);
|
||||
@@ -724,11 +724,11 @@ describe('ProjectStore', () => {
|
||||
// Create spec directory ONLY in worktree location (not in main)
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'only-worktree',
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'specs',
|
||||
'003-worktree-only'
|
||||
);
|
||||
@@ -765,8 +765,8 @@ describe('ProjectStore', () => {
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
// Create .auto-claude directory so project is recognized
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude'), { recursive: true });
|
||||
// Create .aperant directory so project is recognized
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, '.aperant'), { recursive: true });
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
// Task doesn't exist anywhere
|
||||
@@ -778,7 +778,7 @@ describe('ProjectStore', () => {
|
||||
|
||||
it('should reject path traversal attempts in taskId', async () => {
|
||||
// Create a valid spec dir
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', 'valid-task');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', 'valid-task');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = { feature: 'Test', phases: [] };
|
||||
@@ -820,16 +820,16 @@ describe('ProjectStore', () => {
|
||||
describe('unarchiveTasks - multi-location handling', () => {
|
||||
it('should unarchive task from BOTH main and worktree locations', async () => {
|
||||
// Create archived task in both locations
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '004-unarchive-test');
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '004-unarchive-test');
|
||||
mkdirSync(mainSpecsDir, { recursive: true });
|
||||
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'unarchive-worktree',
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'specs',
|
||||
'004-unarchive-test'
|
||||
);
|
||||
@@ -874,7 +874,7 @@ describe('ProjectStore', () => {
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should invalidate cache after archiveTasks', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '005-cache-test');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '005-cache-test');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -915,7 +915,7 @@ describe('ProjectStore', () => {
|
||||
});
|
||||
|
||||
it('should return fresh data after invalidateTasksCache is called', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '006-invalidate-test');
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '006-invalidate-test');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
@@ -959,16 +959,16 @@ describe('ProjectStore', () => {
|
||||
describe('getTasks - worktree deduplication', () => {
|
||||
it('should not duplicate tasks that exist in both main and worktree', async () => {
|
||||
// Create same task in both main and worktree
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '007-dedupe-test');
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.aperant', 'specs', '007-dedupe-test');
|
||||
mkdirSync(mainSpecsDir, { recursive: true });
|
||||
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'dedupe-worktree',
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'specs',
|
||||
'007-dedupe-test'
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from './types';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
import { resetStuckSubtasks } from '../ipc-handlers/task/plan-file-utils';
|
||||
import { AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants';
|
||||
import { APERANT_PATHS, getSpecsDir } from '../../shared/constants';
|
||||
import { projectStore } from '../project-store';
|
||||
import { resolveAuth, resolveAuthFromQueue } from '../ai/auth/resolver';
|
||||
import { resolveModelId } from '../ai/config/phase-config';
|
||||
@@ -112,10 +112,10 @@ export class AgentManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure paths for Python and auto-claude source
|
||||
* Configure paths for Python and aperant source
|
||||
*/
|
||||
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
|
||||
this.processManager.configure(pythonPath, autoBuildSourcePath);
|
||||
configure(pythonPath?: string, aperantSourcePath?: string): void {
|
||||
this.processManager.configure(pythonPath, aperantSourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,11 +216,11 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Scan each project for stuck subtasks
|
||||
for (const project of projects) {
|
||||
if (!project.autoBuildPath) {
|
||||
if (!project.aperantPath) {
|
||||
continue; // Skip projects that haven't been initialized yet
|
||||
}
|
||||
|
||||
const specsDir = path.join(project.path, getSpecsDir(project.autoBuildPath));
|
||||
const specsDir = path.join(project.path, getSpecsDir(project.aperantPath));
|
||||
|
||||
// Check if specs directory exists
|
||||
if (!existsSync(specsDir)) {
|
||||
@@ -235,7 +235,7 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Process each spec directory
|
||||
for (const specDirName of specDirs) {
|
||||
const planPath = path.join(specsDir, specDirName, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
const planPath = path.join(specsDir, specDirName, APERANT_PATHS.IMPLEMENTATION_PLAN);
|
||||
|
||||
// Check if implementation_plan.json exists
|
||||
if (!existsSync(planPath)) {
|
||||
@@ -336,7 +336,7 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Reset stuck subtasks if restarting an existing spec creation task
|
||||
if (specDir) {
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
const planPath = path.join(specDir, APERANT_PATHS.IMPLEMENTATION_PLAN);
|
||||
console.log('[AgentManager] Resetting stuck subtasks before spec creation restart:', planPath);
|
||||
try {
|
||||
const { success, resetCount } = await resetStuckSubtasks(planPath);
|
||||
@@ -375,7 +375,7 @@ export class AgentManager extends EventEmitter {
|
||||
const resolved = await this.resolveAuthFromProviderQueue(specModelId, preferredProvider);
|
||||
|
||||
// Build the serializable session config for the worker
|
||||
const resolvedSpecDir = specDir ?? path.join(projectPath, '.auto-claude', 'specs', taskId);
|
||||
const resolvedSpecDir = specDir ?? path.join(projectPath, '.aperant', 'specs', taskId);
|
||||
const sessionConfig: SerializableSessionConfig = {
|
||||
agentType: 'spec_orchestrator' as const,
|
||||
systemPrompt,
|
||||
@@ -456,7 +456,7 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Resolve the spec directory from specId
|
||||
const project = projectStore.getProjects().find((p) => p.id === projectId || p.path === projectPath);
|
||||
const specsBaseDir = getSpecsDir(project?.autoBuildPath);
|
||||
const specsBaseDir = getSpecsDir(project?.aperantPath);
|
||||
const specDir = path.join(projectPath, specsBaseDir, specId);
|
||||
|
||||
// Load model configuration from task_metadata.json if available
|
||||
@@ -483,7 +483,7 @@ export class AgentManager extends EventEmitter {
|
||||
baseBranch,
|
||||
options.useLocalBranch ?? false,
|
||||
project?.settings?.pushNewBranches !== false,
|
||||
project?.autoBuildPath,
|
||||
project?.aperantPath,
|
||||
);
|
||||
worktreePath = result.worktreePath;
|
||||
// Spec dir in the worktree (spec files were copied by createOrGetWorktree)
|
||||
@@ -578,7 +578,7 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Resolve the spec directory from specId
|
||||
const project = projectStore.getProjects().find((p) => p.id === projectId || p.path === projectPath);
|
||||
const specsBaseDir = getSpecsDir(project?.autoBuildPath);
|
||||
const specsBaseDir = getSpecsDir(project?.aperantPath);
|
||||
const specDir = path.join(projectPath, specsBaseDir, specId);
|
||||
|
||||
// Load model configuration from task_metadata.json if available
|
||||
@@ -820,8 +820,8 @@ export class AgentManager extends EventEmitter {
|
||||
// Reset stuck subtasks before restart to avoid picking up stale in-progress states
|
||||
if (context.specId || context.specDir) {
|
||||
const planPath = context.specDir
|
||||
? path.join(context.specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN)
|
||||
: path.join(context.projectPath, AUTO_BUILD_PATHS.SPECS_DIR, context.specId, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
? path.join(context.specDir, APERANT_PATHS.IMPLEMENTATION_PLAN)
|
||||
: path.join(context.projectPath, APERANT_PATHS.SPECS_DIR, context.specId, APERANT_PATHS.IMPLEMENTATION_PLAN);
|
||||
|
||||
console.log('[AgentManager] Resetting stuck subtasks before restart:', planPath);
|
||||
try {
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('child_process', async (importOriginal) => {
|
||||
|
||||
// Mock project-initializer to avoid child_process.execSync issues
|
||||
vi.mock('../project-initializer', () => ({
|
||||
getAutoBuildPath: vi.fn(() => '/fake/auto-build'),
|
||||
getAperantPath: vi.fn(() => '/fake/aperant'),
|
||||
isInitialized: vi.fn(() => true),
|
||||
initializeProject: vi.fn(),
|
||||
getProjectStorePath: vi.fn(() => '/fake/store/path')
|
||||
@@ -126,7 +126,7 @@ vi.mock('../env-utils', () => ({
|
||||
getAugmentedEnv: vi.fn(() => ({ ...process.env }))
|
||||
}));
|
||||
|
||||
// Mock fs.existsSync for getAutoBuildSourcePath path validation
|
||||
// Mock fs.existsSync for getAperantSourcePath path validation
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
@@ -135,10 +135,10 @@ vi.mock('fs', async (importOriginal) => {
|
||||
// Normalize path separators for cross-platform compatibility
|
||||
// path.join() uses backslashes on Windows, so we normalize to forward slashes
|
||||
const normalizedPath = inputPath.replace(/\\/g, '/');
|
||||
// Return true for the fake auto-build path and its expected files
|
||||
if (normalizedPath === '/fake/auto-build' ||
|
||||
normalizedPath === '/fake/auto-build/runners' ||
|
||||
normalizedPath === '/fake/auto-build/runners/spec_runner.py') {
|
||||
// Return true for the fake aperant path and its expected files
|
||||
if (normalizedPath === '/fake/aperant' ||
|
||||
normalizedPath === '/fake/aperant/runners' ||
|
||||
normalizedPath === '/fake/aperant/runners/spec_runner.py') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -102,7 +102,7 @@ export class AgentProcessManager {
|
||||
private state: AgentState;
|
||||
private events: AgentEvents;
|
||||
private emitter: EventEmitter;
|
||||
private autoBuildSourcePath: string = '';
|
||||
private aperantSourcePath: string = '';
|
||||
|
||||
constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) {
|
||||
this.state = state;
|
||||
@@ -110,14 +110,14 @@ export class AgentProcessManager {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
configure(_pythonPath?: string, autoBuildSourcePath?: string): void {
|
||||
if (autoBuildSourcePath) {
|
||||
this.autoBuildSourcePath = autoBuildSourcePath;
|
||||
configure(_pythonPath?: string, aperantSourcePath?: string): void {
|
||||
if (aperantSourcePath) {
|
||||
this.aperantSourcePath = aperantSourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
getAutoBuildSourcePath(): string {
|
||||
return this.autoBuildSourcePath;
|
||||
getAperantSourcePath(): string {
|
||||
return this.aperantSourcePath;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,31 +493,31 @@ export class AgentProcessManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from project's .auto-claude/.env file
|
||||
* Load environment variables from project's .aperant/.env file
|
||||
* This contains frontend-configured settings like memory configuration
|
||||
*/
|
||||
private loadProjectEnv(projectPath: string): Record<string, string> {
|
||||
// Find project by path to get autoBuildPath
|
||||
// Find project by path to get aperantPath
|
||||
const projects = projectStore.getProjects();
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
|
||||
if (!project?.autoBuildPath) {
|
||||
if (!project?.aperantPath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const envPath = path.join(projectPath, project.autoBuildPath, '.env');
|
||||
const envPath = path.join(projectPath, project.aperantPath, '.env');
|
||||
return this.parseEnvFile(envPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from auto-claude .env file
|
||||
* Load environment variables from aperant .env file
|
||||
*/
|
||||
loadAutoBuildEnv(): Record<string, string> {
|
||||
if (!this.autoBuildSourcePath) {
|
||||
loadAperantEnv(): Record<string, string> {
|
||||
if (!this.aperantSourcePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const envPath = path.join(this.autoBuildSourcePath, '.env');
|
||||
const envPath = path.join(this.aperantSourcePath, '.env');
|
||||
return this.parseEnvFile(envPath);
|
||||
}
|
||||
|
||||
@@ -1036,13 +1036,13 @@ export class AgentProcessManager {
|
||||
* Priority (later sources override earlier):
|
||||
* 1. App-wide memory settings from settings.json (NEW - enables memory from onboarding)
|
||||
* 2. Auto-build source .env (prompts directory) - default values
|
||||
* 3. Project's .auto-claude/.env - Frontend-configured settings (memory, integrations)
|
||||
* 3. Project's .aperant/.env - Frontend-configured settings (memory, integrations)
|
||||
* 4. Project settings (useClaudeMd) - Runtime overrides
|
||||
*/
|
||||
getCombinedEnv(projectPath: string): Record<string, string> {
|
||||
const autoBuildEnv = this.loadAutoBuildEnv();
|
||||
const aperantEnv = this.loadAperantEnv();
|
||||
const projectFileEnv = this.loadProjectEnv(projectPath);
|
||||
const projectSettingsEnv = this.getProjectEnvVars(projectPath);
|
||||
return { ...autoBuildEnv, ...projectFileEnv, ...projectSettingsEnv };
|
||||
return { ...aperantEnv, ...projectFileEnv, ...projectSettingsEnv };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { RoadmapConfig } from './types';
|
||||
import type { IdeationConfig, Idea } from '../../shared/types';
|
||||
import { AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import { APERANT_PATHS } from '../../shared/constants';
|
||||
import { detectRateLimit, createSDKRateLimitInfo } from '../rate-limit-detector';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { transformIdeaFromSnakeCase, transformSessionFromSnakeCase } from '../ipc-handlers/ideation/transformers';
|
||||
@@ -83,8 +83,8 @@ export class AgentQueueManager {
|
||||
isRunning: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roadmapDir = path.join(projectPath, AUTO_BUILD_PATHS.ROADMAP_DIR);
|
||||
const progressPath = path.join(roadmapDir, AUTO_BUILD_PATHS.GENERATION_PROGRESS);
|
||||
const roadmapDir = path.join(projectPath, APERANT_PATHS.ROADMAP_DIR);
|
||||
const progressPath = path.join(roadmapDir, APERANT_PATHS.GENERATION_PROGRESS);
|
||||
|
||||
// Ensure roadmap directory exists
|
||||
if (!existsSync(roadmapDir)) {
|
||||
@@ -120,8 +120,8 @@ export class AgentQueueManager {
|
||||
try {
|
||||
const progressPath = path.join(
|
||||
projectPath,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.GENERATION_PROGRESS
|
||||
APERANT_PATHS.ROADMAP_DIR,
|
||||
APERANT_PATHS.GENERATION_PROGRESS
|
||||
);
|
||||
|
||||
if (existsSync(progressPath)) {
|
||||
@@ -225,7 +225,7 @@ export class AgentQueueManager {
|
||||
// which handles both dev (apps/desktop/prompts/) and production (resourcesPath/prompts/)
|
||||
const promptsDir = resolvePromptsDir();
|
||||
|
||||
const outputDir = path.join(projectPath, '.auto-claude', 'ideation');
|
||||
const outputDir = path.join(projectPath, '.aperant', 'ideation');
|
||||
|
||||
// Emit initial progress
|
||||
this.emitter.emit('ideation-progress', projectId, {
|
||||
@@ -469,7 +469,7 @@ export class AgentQueueManager {
|
||||
this.clearRoadmapProgress(projectPath);
|
||||
|
||||
// Load and emit the complete roadmap
|
||||
const roadmapFilePath = path.join(projectPath, '.auto-claude', 'roadmap', 'roadmap.json');
|
||||
const roadmapFilePath = path.join(projectPath, '.aperant', 'roadmap', 'roadmap.json');
|
||||
if (existsSync(roadmapFilePath)) {
|
||||
try {
|
||||
const content = await fsPromises.readFile(roadmapFilePath, 'utf-8');
|
||||
|
||||
@@ -40,8 +40,8 @@ const __dirname = path.dirname(__filename);
|
||||
*/
|
||||
function resolveWorkerPath(): string {
|
||||
if (app.isPackaged) {
|
||||
// Production: worker is bundled alongside other main-process code
|
||||
return path.join(process.resourcesPath, 'app', 'main', 'ai', 'agent', 'worker.js');
|
||||
// Production: worker is inside app.asar at out/main/ai/agent/worker.js
|
||||
return path.join(process.resourcesPath, 'app.asar', 'out', 'main', 'ai', 'agent', 'worker.js');
|
||||
}
|
||||
// Dev: electron-vite outputs worker at out/main/ai/agent/worker.js
|
||||
// because the Rollup input key is 'ai/agent/worker'.
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Tests for AI Auth Resolver
|
||||
*
|
||||
* Validates the multi-stage credential resolution fallback chain,
|
||||
* provider account resolution, settings accessor registration,
|
||||
* environment variable fallback, and Z.AI endpoint routing.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock token-refresh before importing resolver
|
||||
// Path resolution from src/main/ai/auth/__tests__/:
|
||||
// ../ = src/main/ai/auth/
|
||||
// ../../ = src/main/ai/
|
||||
// ../../../ = src/main/
|
||||
// So ../../../claude-profile/ = src/main/claude-profile/
|
||||
vi.mock('../../../claude-profile/token-refresh', () => ({
|
||||
ensureValidToken: vi.fn(),
|
||||
reactiveTokenRefresh: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock profile-scorer
|
||||
vi.mock('../../../claude-profile/profile-scorer', () => ({
|
||||
scoreProviderAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock model equivalence
|
||||
// ../../../../shared/ = src/shared/ (4 levels up from __tests__ = src/)
|
||||
vi.mock('../../../../shared/constants/models', () => ({
|
||||
resolveModelEquivalent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock provider factory detection
|
||||
// ../../providers/ = src/main/ai/providers/
|
||||
vi.mock('../../providers/factory', () => ({
|
||||
detectProviderFromModel: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ensureValidToken, reactiveTokenRefresh } from '../../../claude-profile/token-refresh';
|
||||
import { scoreProviderAccount } from '../../../claude-profile/profile-scorer';
|
||||
import { resolveModelEquivalent } from '../../../../shared/constants/models';
|
||||
import { detectProviderFromModel } from '../../providers/factory';
|
||||
import {
|
||||
resolveAuth,
|
||||
hasCredentials,
|
||||
registerSettingsAccessor,
|
||||
refreshOAuthTokenReactive,
|
||||
resolveAuthFromQueue,
|
||||
buildDefaultQueueConfig,
|
||||
} from '../resolver';
|
||||
|
||||
const mockEnsureValidToken = vi.mocked(ensureValidToken);
|
||||
const mockReactiveTokenRefresh = vi.mocked(reactiveTokenRefresh);
|
||||
const mockScoreProviderAccount = vi.mocked(scoreProviderAccount);
|
||||
const mockResolveModelEquivalent = vi.mocked(resolveModelEquivalent);
|
||||
const _mockDetectProviderFromModel = vi.mocked(detectProviderFromModel);
|
||||
|
||||
// Helper: reset the module-level settings accessor between tests
|
||||
function clearSettingsAccessor() {
|
||||
registerSettingsAccessor(() => undefined);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearSettingsAccessor();
|
||||
// Clean up any environment variable side effects
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.OPENAI_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.OPENAI_BASE_URL;
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// registerSettingsAccessor
|
||||
// =============================================================================
|
||||
|
||||
describe('registerSettingsAccessor', () => {
|
||||
it('wires up settings so subsequent calls read from the accessor', async () => {
|
||||
registerSettingsAccessor((key) => (key === 'globalAnthropicApiKey' ? 'sk-from-settings' : undefined));
|
||||
|
||||
const auth = await resolveAuth({ provider: 'anthropic' });
|
||||
expect(auth).not.toBeNull();
|
||||
expect(auth?.apiKey).toBe('sk-from-settings');
|
||||
expect(auth?.source).toBe('profile-api-key');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Stage 1: Profile OAuth Token
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuth — Stage 1: Profile OAuth', () => {
|
||||
it('returns oauth token for anthropic when ensureValidToken resolves', async () => {
|
||||
mockEnsureValidToken.mockResolvedValueOnce({ token: 'oauth-token-abc', wasRefreshed: false });
|
||||
|
||||
const auth = await resolveAuth({ provider: 'anthropic', configDir: '/home/.config/claude' });
|
||||
|
||||
expect(auth).not.toBeNull();
|
||||
expect(auth?.apiKey).toBe('oauth-token-abc');
|
||||
expect(auth?.source).toBe('profile-oauth');
|
||||
expect(auth?.headers).toMatchObject({ 'anthropic-beta': expect.stringContaining('oauth') });
|
||||
});
|
||||
|
||||
it('includes custom base URL when ANTHROPIC_BASE_URL is set', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://proxy.example.com';
|
||||
mockEnsureValidToken.mockResolvedValueOnce({ token: 'oauth-token-abc', wasRefreshed: false });
|
||||
|
||||
const auth = await resolveAuth({ provider: 'anthropic' });
|
||||
|
||||
expect(auth?.baseURL).toBe('https://proxy.example.com');
|
||||
});
|
||||
|
||||
it('skips oauth stage for non-anthropic providers', async () => {
|
||||
// openai has no oauth stage; should fall through to environment
|
||||
process.env.OPENAI_API_KEY = 'sk-env-openai';
|
||||
|
||||
const auth = await resolveAuth({ provider: 'openai' });
|
||||
|
||||
expect(mockEnsureValidToken).not.toHaveBeenCalled();
|
||||
expect(auth?.source).toBe('environment');
|
||||
});
|
||||
|
||||
it('falls through when ensureValidToken throws', async () => {
|
||||
mockEnsureValidToken.mockRejectedValueOnce(new Error('keychain locked'));
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-env-fallback';
|
||||
|
||||
const auth = await resolveAuth({ provider: 'anthropic' });
|
||||
|
||||
expect(auth?.apiKey).toBe('sk-env-fallback');
|
||||
expect(auth?.source).toBe('environment');
|
||||
});
|
||||
|
||||
it('falls through when ensureValidToken returns no token', async () => {
|
||||
mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false });
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-env-fallback';
|
||||
|
||||
const auth = await resolveAuth({ provider: 'anthropic' });
|
||||
|
||||
expect(auth?.source).toBe('environment');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Stage 2: Profile API Key (from settings)
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuth — Stage 2: Profile API Key', () => {
|
||||
it('returns api-key from settings when no oauth token available', async () => {
|
||||
mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false });
|
||||
registerSettingsAccessor((key) => (key === 'globalAnthropicApiKey' ? 'sk-settings-key' : undefined));
|
||||
|
||||
const auth = await resolveAuth({ provider: 'anthropic' });
|
||||
|
||||
expect(auth?.apiKey).toBe('sk-settings-key');
|
||||
expect(auth?.source).toBe('profile-api-key');
|
||||
});
|
||||
|
||||
it('includes base URL from environment even for settings-based keys', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://custom.proxy.io';
|
||||
mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false });
|
||||
registerSettingsAccessor((key) => (key === 'globalAnthropicApiKey' ? 'sk-settings' : undefined));
|
||||
|
||||
const auth = await resolveAuth({ provider: 'anthropic' });
|
||||
|
||||
expect(auth?.baseURL).toBe('https://custom.proxy.io');
|
||||
});
|
||||
|
||||
it('returns null from settings stage when accessor returns nothing', async () => {
|
||||
mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false });
|
||||
// settings accessor returns undefined for everything, env also not set
|
||||
const auth = await resolveAuth({ provider: 'anthropic' });
|
||||
expect(auth).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Stage 3: Environment Variable
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuth — Stage 3: Environment Variable', () => {
|
||||
it('returns env key for openai', async () => {
|
||||
process.env.OPENAI_API_KEY = 'sk-env-openai-123';
|
||||
|
||||
const auth = await resolveAuth({ provider: 'openai' });
|
||||
|
||||
expect(auth?.apiKey).toBe('sk-env-openai-123');
|
||||
expect(auth?.source).toBe('environment');
|
||||
});
|
||||
|
||||
it('includes base URL from env when OPENAI_BASE_URL is set', async () => {
|
||||
process.env.OPENAI_API_KEY = 'sk-env-openai';
|
||||
process.env.OPENAI_BASE_URL = 'https://openai-proxy.com';
|
||||
|
||||
const auth = await resolveAuth({ provider: 'openai' });
|
||||
|
||||
expect(auth?.baseURL).toBe('https://openai-proxy.com');
|
||||
});
|
||||
|
||||
it('returns null for bedrock (no env var defined)', async () => {
|
||||
const auth = await resolveAuth({ provider: 'bedrock' });
|
||||
expect(auth).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Stage 4: Default Credentials (no-auth providers)
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuth — Stage 4: Default Credentials', () => {
|
||||
it('returns empty api key for ollama', async () => {
|
||||
const auth = await resolveAuth({ provider: 'ollama' });
|
||||
|
||||
expect(auth).not.toBeNull();
|
||||
expect(auth?.apiKey).toBe('');
|
||||
expect(auth?.source).toBe('default');
|
||||
});
|
||||
|
||||
it('returns null for unknown provider with no credentials', async () => {
|
||||
const auth = await resolveAuth({ provider: 'groq' });
|
||||
expect(auth).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// hasCredentials
|
||||
// =============================================================================
|
||||
|
||||
describe('hasCredentials', () => {
|
||||
it('returns true when credentials resolve', async () => {
|
||||
process.env.OPENAI_API_KEY = 'sk-test';
|
||||
expect(await hasCredentials({ provider: 'openai' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for ollama (no-auth)', async () => {
|
||||
expect(await hasCredentials({ provider: 'ollama' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no credentials available', async () => {
|
||||
expect(await hasCredentials({ provider: 'groq' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// refreshOAuthTokenReactive
|
||||
// =============================================================================
|
||||
|
||||
describe('refreshOAuthTokenReactive', () => {
|
||||
it('returns new token from reactiveTokenRefresh', async () => {
|
||||
mockReactiveTokenRefresh.mockResolvedValueOnce({ token: 'refreshed-token-xyz', wasRefreshed: true });
|
||||
|
||||
const result = await refreshOAuthTokenReactive('/some/config/dir');
|
||||
|
||||
expect(result).toBe('refreshed-token-xyz');
|
||||
expect(mockReactiveTokenRefresh).toHaveBeenCalledWith('/some/config/dir');
|
||||
});
|
||||
|
||||
it('returns null when reactiveTokenRefresh returns no token', async () => {
|
||||
mockReactiveTokenRefresh.mockResolvedValueOnce({ token: null, wasRefreshed: false });
|
||||
|
||||
const result = await refreshOAuthTokenReactive(undefined);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when reactiveTokenRefresh throws', async () => {
|
||||
mockReactiveTokenRefresh.mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
const result = await refreshOAuthTokenReactive('/config');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Provider Account Resolution (Stage 0)
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuth — Stage 0: Provider Account', () => {
|
||||
it('returns api-key auth from providerAccounts setting', async () => {
|
||||
const accounts = [
|
||||
{
|
||||
provider: 'openai',
|
||||
isActive: true,
|
||||
authType: 'api-key',
|
||||
apiKey: 'sk-provider-account-key',
|
||||
},
|
||||
];
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify(accounts);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const auth = await resolveAuth({ provider: 'openai' });
|
||||
|
||||
expect(auth?.apiKey).toBe('sk-provider-account-key');
|
||||
expect(auth?.source).toBe('profile-api-key');
|
||||
});
|
||||
|
||||
it('routes z.ai subscription to coding API endpoint', async () => {
|
||||
const accounts = [
|
||||
{
|
||||
provider: 'zai',
|
||||
isActive: true,
|
||||
authType: 'api-key',
|
||||
apiKey: 'zhipu-key',
|
||||
billingModel: 'subscription',
|
||||
},
|
||||
];
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify(accounts);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const auth = await resolveAuth({ provider: 'zai' });
|
||||
|
||||
expect(auth?.apiKey).toBe('zhipu-key');
|
||||
expect(auth?.baseURL).toContain('/coding/paas/v4');
|
||||
});
|
||||
|
||||
it('routes z.ai pay-per-use to general API endpoint', async () => {
|
||||
const accounts = [
|
||||
{
|
||||
provider: 'zai',
|
||||
isActive: true,
|
||||
authType: 'api-key',
|
||||
apiKey: 'zhipu-key',
|
||||
billingModel: 'pay-per-use',
|
||||
},
|
||||
];
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify(accounts);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const auth = await resolveAuth({ provider: 'zai' });
|
||||
|
||||
expect(auth?.baseURL).toContain('/paas/v4');
|
||||
expect(auth?.baseURL).not.toContain('/coding/');
|
||||
});
|
||||
|
||||
it('skips inactive accounts and falls through', async () => {
|
||||
const accounts = [
|
||||
{ provider: 'openai', isActive: false, authType: 'api-key', apiKey: 'sk-inactive' },
|
||||
];
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify(accounts);
|
||||
return undefined;
|
||||
});
|
||||
process.env.OPENAI_API_KEY = 'sk-env-fallback';
|
||||
|
||||
const auth = await resolveAuth({ provider: 'openai' });
|
||||
|
||||
expect(auth?.source).toBe('environment');
|
||||
});
|
||||
|
||||
it('handles malformed providerAccounts JSON gracefully', async () => {
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return 'not-valid-json{{';
|
||||
return undefined;
|
||||
});
|
||||
process.env.OPENAI_API_KEY = 'sk-fallback';
|
||||
|
||||
const auth = await resolveAuth({ provider: 'openai' });
|
||||
expect(auth?.source).toBe('environment');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// resolveAuthFromQueue
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuthFromQueue', () => {
|
||||
const baseAccount = {
|
||||
id: 'acc-1',
|
||||
provider: 'anthropic' as const,
|
||||
authType: 'api-key' as const,
|
||||
apiKey: 'sk-queue-key',
|
||||
isActive: true,
|
||||
name: 'Primary Account',
|
||||
billingModel: 'pay-per-use' as const,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockScoreProviderAccount.mockReturnValue({ available: true, score: 100 });
|
||||
mockResolveModelEquivalent.mockReturnValue({
|
||||
modelId: 'claude-sonnet-4-5-20250929',
|
||||
reasoning: { type: 'none' },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth from the first available account in queue', async () => {
|
||||
const result = await resolveAuthFromQueue('sonnet', [baseAccount]);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.accountId).toBe('acc-1');
|
||||
expect(result?.apiKey).toBe('sk-queue-key');
|
||||
expect(result?.resolvedProvider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('skips excluded account IDs', async () => {
|
||||
const result = await resolveAuthFromQueue('sonnet', [baseAccount], {
|
||||
excludeAccountIds: ['acc-1'],
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('skips unavailable accounts', async () => {
|
||||
mockScoreProviderAccount.mockReturnValueOnce({ available: false, score: 0 });
|
||||
|
||||
const result = await resolveAuthFromQueue('sonnet', [baseAccount]);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when queue is empty', async () => {
|
||||
const result = await resolveAuthFromQueue('sonnet', []);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the resolved model ID from equivalence table', async () => {
|
||||
mockResolveModelEquivalent.mockReturnValueOnce({
|
||||
modelId: 'claude-haiku-4-5',
|
||||
reasoning: { type: 'none' },
|
||||
});
|
||||
|
||||
const result = await resolveAuthFromQueue('haiku', [baseAccount]);
|
||||
|
||||
expect(result?.resolvedModelId).toBe('claude-haiku-4-5');
|
||||
});
|
||||
|
||||
it('falls through to next account when first has no credentials', async () => {
|
||||
const noKeyAccount = { ...baseAccount, id: 'acc-no-key', apiKey: undefined, authType: 'api-key' as const };
|
||||
const goodAccount = { ...baseAccount, id: 'acc-2' };
|
||||
|
||||
const result = await resolveAuthFromQueue('sonnet', [noKeyAccount, goodAccount]);
|
||||
|
||||
expect(result?.accountId).toBe('acc-2');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// buildDefaultQueueConfig
|
||||
// =============================================================================
|
||||
|
||||
describe('buildDefaultQueueConfig', () => {
|
||||
it('returns undefined when no settings accessor is registered', () => {
|
||||
// accessor returns undefined for everything
|
||||
const result = buildDefaultQueueConfig('claude-sonnet-4-5-20250929');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns sorted queue when providerAccounts are configured', () => {
|
||||
const accounts = [
|
||||
{ id: 'b', provider: 'openai', isActive: true },
|
||||
{ id: 'a', provider: 'anthropic', isActive: true },
|
||||
];
|
||||
const priorityOrder = ['a', 'b'];
|
||||
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify(accounts);
|
||||
if (key === 'globalPriorityOrder') return JSON.stringify(priorityOrder);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = buildDefaultQueueConfig('claude-sonnet-4-5-20250929');
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.queue[0].id).toBe('a');
|
||||
expect(result?.queue[1].id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when providerAccounts is empty array', () => {
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify([]);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = buildDefaultQueueConfig('sonnet');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns accounts in natural order when no priority order is set', () => {
|
||||
const accounts = [
|
||||
{ id: 'x', provider: 'groq', isActive: true },
|
||||
{ id: 'y', provider: 'mistral', isActive: true },
|
||||
];
|
||||
registerSettingsAccessor((key) => {
|
||||
if (key === 'providerAccounts') return JSON.stringify(accounts);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = buildDefaultQueueConfig('some-model');
|
||||
|
||||
expect(result?.queue[0].id).toBe('x');
|
||||
expect(result?.queue[1].id).toBe('y');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tests for AI Auth Types
|
||||
*
|
||||
* Validates that exported constants have the correct mappings
|
||||
* for environment variables, settings keys, and base URL env vars.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PROVIDER_ENV_VARS,
|
||||
PROVIDER_SETTINGS_KEY,
|
||||
PROVIDER_BASE_URL_ENV,
|
||||
} from '../types';
|
||||
|
||||
describe('PROVIDER_ENV_VARS', () => {
|
||||
it('maps anthropic to ANTHROPIC_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.anthropic).toBe('ANTHROPIC_API_KEY');
|
||||
});
|
||||
|
||||
it('maps openai to OPENAI_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.openai).toBe('OPENAI_API_KEY');
|
||||
});
|
||||
|
||||
it('maps google to GOOGLE_GENERATIVE_AI_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.google).toBe('GOOGLE_GENERATIVE_AI_API_KEY');
|
||||
});
|
||||
|
||||
it('maps bedrock to undefined (uses AWS credential chain)', () => {
|
||||
expect(PROVIDER_ENV_VARS.bedrock).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps azure to AZURE_OPENAI_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.azure).toBe('AZURE_OPENAI_API_KEY');
|
||||
});
|
||||
|
||||
it('maps mistral to MISTRAL_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.mistral).toBe('MISTRAL_API_KEY');
|
||||
});
|
||||
|
||||
it('maps groq to GROQ_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.groq).toBe('GROQ_API_KEY');
|
||||
});
|
||||
|
||||
it('maps xai to XAI_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.xai).toBe('XAI_API_KEY');
|
||||
});
|
||||
|
||||
it('maps openrouter to OPENROUTER_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.openrouter).toBe('OPENROUTER_API_KEY');
|
||||
});
|
||||
|
||||
it('maps zai to ZHIPU_API_KEY', () => {
|
||||
expect(PROVIDER_ENV_VARS.zai).toBe('ZHIPU_API_KEY');
|
||||
});
|
||||
|
||||
it('maps ollama to undefined (no auth required)', () => {
|
||||
expect(PROVIDER_ENV_VARS.ollama).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROVIDER_SETTINGS_KEY', () => {
|
||||
it('maps anthropic to globalAnthropicApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.anthropic).toBe('globalAnthropicApiKey');
|
||||
});
|
||||
|
||||
it('maps openai to globalOpenAIApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.openai).toBe('globalOpenAIApiKey');
|
||||
});
|
||||
|
||||
it('maps google to globalGoogleApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.google).toBe('globalGoogleApiKey');
|
||||
});
|
||||
|
||||
it('maps groq to globalGroqApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.groq).toBe('globalGroqApiKey');
|
||||
});
|
||||
|
||||
it('maps mistral to globalMistralApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.mistral).toBe('globalMistralApiKey');
|
||||
});
|
||||
|
||||
it('maps xai to globalXAIApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.xai).toBe('globalXAIApiKey');
|
||||
});
|
||||
|
||||
it('maps azure to globalAzureApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.azure).toBe('globalAzureApiKey');
|
||||
});
|
||||
|
||||
it('maps openrouter to globalOpenRouterApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.openrouter).toBe('globalOpenRouterApiKey');
|
||||
});
|
||||
|
||||
it('maps zai to globalZAIApiKey', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.zai).toBe('globalZAIApiKey');
|
||||
});
|
||||
|
||||
it('does not have a key for bedrock', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.bedrock).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not have a key for ollama', () => {
|
||||
expect(PROVIDER_SETTINGS_KEY.ollama).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROVIDER_BASE_URL_ENV', () => {
|
||||
it('maps anthropic to ANTHROPIC_BASE_URL', () => {
|
||||
expect(PROVIDER_BASE_URL_ENV.anthropic).toBe('ANTHROPIC_BASE_URL');
|
||||
});
|
||||
|
||||
it('maps openai to OPENAI_BASE_URL', () => {
|
||||
expect(PROVIDER_BASE_URL_ENV.openai).toBe('OPENAI_BASE_URL');
|
||||
});
|
||||
|
||||
it('maps azure to AZURE_OPENAI_ENDPOINT', () => {
|
||||
expect(PROVIDER_BASE_URL_ENV.azure).toBe('AZURE_OPENAI_ENDPOINT');
|
||||
});
|
||||
|
||||
it('does not define base URL env for other providers', () => {
|
||||
expect(PROVIDER_BASE_URL_ENV.google).toBeUndefined();
|
||||
expect(PROVIDER_BASE_URL_ENV.groq).toBeUndefined();
|
||||
expect(PROVIDER_BASE_URL_ENV.mistral).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,7 @@ export async function startCodexOAuthFlow(): Promise<CodexAuthResult> {
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
authUrl.searchParams.set('originator', 'auto-claude');
|
||||
authUrl.searchParams.set('originator', 'aperant');
|
||||
authUrl.searchParams.set('codex_cli_simplified_flow', 'true');
|
||||
|
||||
debugLog('Built authorization URL', { url: authUrl.toString() });
|
||||
@@ -253,7 +253,7 @@ export async function startCodexOAuthFlow(): Promise<CodexAuthResult> {
|
||||
<body style="font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a1a; color: #e0e0e0;">
|
||||
<div style="text-align: center;">
|
||||
<h2 style="color: #4ade80;">Authentication successful!</h2>
|
||||
<p>You can close this tab and return to Auto Claude.</p>
|
||||
<p>You can close this tab and return to Aperant.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Tests for Client Factory
|
||||
*
|
||||
* Validates createSimpleClient() and createAgentClient() — model resolution,
|
||||
* credential wiring, tool registry binding, queue-based auth, and cleanup.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock auth resolver — inline to avoid hoisting issues
|
||||
vi.mock('../../auth/resolver', () => ({
|
||||
resolveAuth: vi.fn().mockResolvedValue({ apiKey: 'sk-default', source: 'environment' }),
|
||||
resolveAuthFromQueue: vi.fn().mockResolvedValue(null),
|
||||
buildDefaultQueueConfig: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock provider factory — inline
|
||||
vi.mock('../../providers/factory', () => ({
|
||||
createProvider: vi.fn().mockReturnValue({ type: 'language-model', modelId: 'mock-model-id' }),
|
||||
detectProviderFromModel: vi.fn().mockReturnValue('anthropic'),
|
||||
}));
|
||||
|
||||
// Mock phase config — inline
|
||||
vi.mock('../../config/phase-config', () => ({
|
||||
resolveModelId: vi.fn().mockReturnValue('claude-haiku-4-5'),
|
||||
}));
|
||||
|
||||
// Mock agent configs — inline
|
||||
vi.mock('../../config/agent-configs', () => ({
|
||||
getDefaultThinkingLevel: vi.fn().mockReturnValue('medium'),
|
||||
getRequiredMcpServers: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
// Mock MCP client module — inline
|
||||
vi.mock('../../mcp/client', () => ({
|
||||
createMcpClientsForAgent: vi.fn().mockResolvedValue([]),
|
||||
closeAllMcpClients: vi.fn().mockResolvedValue(undefined),
|
||||
mergeMcpTools: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Mock tool registry — inline
|
||||
vi.mock('../../tools/build-registry', () => ({
|
||||
buildToolRegistry: vi.fn().mockReturnValue({
|
||||
getToolsForAgent: vi.fn().mockReturnValue({ Read: {}, Write: {} }),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock config/types resolveReasoningParams — inline
|
||||
vi.mock('../../config/types', () => ({
|
||||
resolveReasoningParams: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
import { resolveAuth, resolveAuthFromQueue, buildDefaultQueueConfig } from '../../auth/resolver';
|
||||
import { createProvider, detectProviderFromModel } from '../../providers/factory';
|
||||
import { resolveModelId } from '../../config/phase-config';
|
||||
import { getDefaultThinkingLevel, getRequiredMcpServers } from '../../config/agent-configs';
|
||||
import { createMcpClientsForAgent, closeAllMcpClients, mergeMcpTools } from '../../mcp/client';
|
||||
import { buildToolRegistry } from '../../tools/build-registry';
|
||||
import { createSimpleClient, createAgentClient } from '../factory';
|
||||
import type { LanguageModel, Tool } from 'ai';
|
||||
import type { ToolContext } from '../../tools/types';
|
||||
import type { AgentClientConfig } from '../types';
|
||||
import type { ProviderAccount } from '../../../../shared/types/provider-account';
|
||||
import type { McpClientResult } from '../../mcp/types';
|
||||
import type { ToolRegistry } from '../../tools/registry';
|
||||
|
||||
const mockResolveAuth = vi.mocked(resolveAuth);
|
||||
const mockResolveAuthFromQueue = vi.mocked(resolveAuthFromQueue);
|
||||
const mockBuildDefaultQueueConfig = vi.mocked(buildDefaultQueueConfig);
|
||||
const mockCreateProvider = vi.mocked(createProvider);
|
||||
const mockDetectProviderFromModel = vi.mocked(detectProviderFromModel);
|
||||
const mockResolveModelId = vi.mocked(resolveModelId);
|
||||
const mockGetDefaultThinkingLevel = vi.mocked(getDefaultThinkingLevel);
|
||||
const mockGetRequiredMcpServers = vi.mocked(getRequiredMcpServers);
|
||||
const mockCreateMcpClientsForAgent = vi.mocked(createMcpClientsForAgent);
|
||||
const mockCloseAllMcpClients = vi.mocked(closeAllMcpClients);
|
||||
const mockMergeMcpTools = vi.mocked(mergeMcpTools);
|
||||
const mockBuildToolRegistry = vi.mocked(buildToolRegistry);
|
||||
|
||||
const FAKE_MODEL = { type: 'language-model', modelId: 'mock-model-id' };
|
||||
|
||||
const baseToolContext = {
|
||||
cwd: '/project',
|
||||
projectDir: '/project',
|
||||
specDir: '/project/.aperant/specs/001',
|
||||
securityProfile: 'standard' as const,
|
||||
} as unknown as ToolContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Re-establish defaults after clearAllMocks
|
||||
mockResolveAuth.mockResolvedValue({ apiKey: 'sk-default', source: 'environment' });
|
||||
mockResolveAuthFromQueue.mockResolvedValue(null);
|
||||
mockBuildDefaultQueueConfig.mockReturnValue(undefined);
|
||||
mockCreateProvider.mockReturnValue(FAKE_MODEL as unknown as LanguageModel);
|
||||
mockDetectProviderFromModel.mockReturnValue('anthropic');
|
||||
mockResolveModelId.mockReturnValue('claude-haiku-4-5');
|
||||
mockGetDefaultThinkingLevel.mockReturnValue('medium');
|
||||
mockGetRequiredMcpServers.mockReturnValue([]);
|
||||
mockCreateMcpClientsForAgent.mockResolvedValue([]);
|
||||
mockCloseAllMcpClients.mockResolvedValue(undefined);
|
||||
mockMergeMcpTools.mockReturnValue({});
|
||||
|
||||
// ToolRegistry mock: getToolsForAgent returns a basic tools map
|
||||
const mockRegistry = { getToolsForAgent: vi.fn().mockReturnValue({ Read: {}, Write: {} }) };
|
||||
mockBuildToolRegistry.mockReturnValue(mockRegistry as unknown as ToolRegistry);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createSimpleClient
|
||||
// =============================================================================
|
||||
|
||||
describe('createSimpleClient', () => {
|
||||
it('returns model, resolvedModelId, tools, systemPrompt, maxSteps, and thinkingLevel', async () => {
|
||||
const result = await createSimpleClient({ systemPrompt: 'You are helpful.' });
|
||||
|
||||
expect(result.model).toBe(FAKE_MODEL);
|
||||
expect(result.resolvedModelId).toBeDefined();
|
||||
expect(result.tools).toBeDefined();
|
||||
expect(result.systemPrompt).toBe('You are helpful.');
|
||||
expect(result.maxSteps).toBe(1);
|
||||
expect(result.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('defaults modelShorthand to haiku when not specified', async () => {
|
||||
await createSimpleClient({ systemPrompt: 'Test' });
|
||||
expect(mockResolveModelId).toHaveBeenCalledWith('haiku');
|
||||
});
|
||||
|
||||
it('uses the specified modelShorthand', async () => {
|
||||
await createSimpleClient({ systemPrompt: 'Test', modelShorthand: 'sonnet' });
|
||||
expect(mockResolveModelId).toHaveBeenCalledWith('sonnet');
|
||||
});
|
||||
|
||||
it('uses the specified thinkingLevel', async () => {
|
||||
const result = await createSimpleClient({ systemPrompt: 'Test', thinkingLevel: 'high' });
|
||||
expect(result.thinkingLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('uses specified maxSteps', async () => {
|
||||
const result = await createSimpleClient({ systemPrompt: 'Test', maxSteps: 5 });
|
||||
expect(result.maxSteps).toBe(5);
|
||||
});
|
||||
|
||||
it('wires resolved auth credentials into createProvider', async () => {
|
||||
mockResolveAuth.mockResolvedValueOnce({
|
||||
apiKey: 'sk-resolved',
|
||||
source: 'environment',
|
||||
baseURL: 'https://custom.api.com',
|
||||
});
|
||||
|
||||
await createSimpleClient({ systemPrompt: 'Test' });
|
||||
|
||||
expect(mockCreateProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
apiKey: 'sk-resolved',
|
||||
baseURL: 'https://custom.api.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes tools option through to result', async () => {
|
||||
const customTools = { myTool: {} as unknown as Tool };
|
||||
const result = await createSimpleClient({ systemPrompt: 'Test', tools: customTools });
|
||||
expect(result.tools).toBe(customTools);
|
||||
});
|
||||
|
||||
it('uses queue-based resolution when queueConfig is provided', async () => {
|
||||
const queueAuth = {
|
||||
apiKey: 'sk-queue',
|
||||
source: 'profile-api-key' as const,
|
||||
accountId: 'acc-1',
|
||||
resolvedProvider: 'anthropic' as const,
|
||||
resolvedModelId: 'claude-opus-4-6',
|
||||
reasoningConfig: { type: 'none' as const },
|
||||
};
|
||||
mockResolveAuthFromQueue.mockResolvedValueOnce(queueAuth);
|
||||
|
||||
const queueConfig = {
|
||||
queue: [{ id: 'acc-1' } as unknown as ProviderAccount],
|
||||
requestedModel: 'claude-opus-4-6',
|
||||
};
|
||||
|
||||
const result = await createSimpleClient({ systemPrompt: 'Test', queueConfig });
|
||||
|
||||
expect(mockResolveAuthFromQueue).toHaveBeenCalled();
|
||||
expect(result.queueAuth).toBe(queueAuth);
|
||||
expect(result.resolvedModelId).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('throws when queueConfig is provided but no account is available', async () => {
|
||||
mockResolveAuthFromQueue.mockResolvedValueOnce(null);
|
||||
|
||||
const queueConfig = { queue: [], requestedModel: 'sonnet' };
|
||||
|
||||
await expect(
|
||||
createSimpleClient({ systemPrompt: 'Test', queueConfig }),
|
||||
).rejects.toThrow('No available account in priority queue');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createAgentClient
|
||||
// =============================================================================
|
||||
|
||||
describe('createAgentClient', () => {
|
||||
const baseConfig = {
|
||||
agentType: 'coder' as const,
|
||||
systemPrompt: 'You are a coder.',
|
||||
toolContext: baseToolContext,
|
||||
phase: 'coding' as const,
|
||||
};
|
||||
|
||||
it('returns model, tools, mcpClients, systemPrompt, maxSteps, thinkingLevel, and cleanup', async () => {
|
||||
const result = await createAgentClient(baseConfig);
|
||||
|
||||
expect(result.model).toBe(FAKE_MODEL);
|
||||
expect(result.tools).toBeDefined();
|
||||
expect(result.mcpClients).toEqual([]);
|
||||
expect(result.systemPrompt).toBe('You are a coder.');
|
||||
expect(result.maxSteps).toBe(200);
|
||||
expect(result.thinkingLevel).toBeDefined();
|
||||
expect(typeof result.cleanup).toBe('function');
|
||||
});
|
||||
|
||||
it('uses agent-config default thinking level', async () => {
|
||||
mockGetDefaultThinkingLevel.mockReturnValueOnce('high');
|
||||
|
||||
const result = await createAgentClient(baseConfig);
|
||||
|
||||
expect(result.thinkingLevel).toBe('high');
|
||||
expect(mockGetDefaultThinkingLevel).toHaveBeenCalledWith('coder');
|
||||
});
|
||||
|
||||
it('overrides thinking level when thinkingLevel is specified', async () => {
|
||||
const result = await createAgentClient({ ...baseConfig, thinkingLevel: 'low' });
|
||||
expect(result.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('uses specified maxSteps', async () => {
|
||||
const result = await createAgentClient({ ...baseConfig, maxSteps: 50 });
|
||||
expect(result.maxSteps).toBe(50);
|
||||
});
|
||||
|
||||
it('calls getToolsForAgent with agentType and toolContext', async () => {
|
||||
const mockRegistry = { getToolsForAgent: vi.fn().mockReturnValue({ Read: {}, Write: {} }) };
|
||||
mockBuildToolRegistry.mockReturnValueOnce(mockRegistry as unknown as ToolRegistry);
|
||||
|
||||
await createAgentClient(baseConfig);
|
||||
|
||||
expect(mockRegistry.getToolsForAgent).toHaveBeenCalledWith('coder', baseToolContext);
|
||||
});
|
||||
|
||||
it('creates MCP clients when agent requires servers', async () => {
|
||||
const mockMcpClient = { serverId: 'context7', tools: { ctx7_tool: {} }, close: vi.fn() };
|
||||
mockGetRequiredMcpServers.mockReturnValueOnce(['context7']);
|
||||
mockCreateMcpClientsForAgent.mockResolvedValueOnce([mockMcpClient] as unknown as McpClientResult[]);
|
||||
mockMergeMcpTools.mockReturnValueOnce({ ctx7_tool: {} });
|
||||
|
||||
const result = await createAgentClient(baseConfig);
|
||||
|
||||
expect(mockCreateMcpClientsForAgent).toHaveBeenCalledWith('coder', expect.any(Object));
|
||||
expect(result.mcpClients).toHaveLength(1);
|
||||
expect(result.tools).toHaveProperty('ctx7_tool');
|
||||
});
|
||||
|
||||
it('cleanup calls closeAllMcpClients with the client list', async () => {
|
||||
const result = await createAgentClient(baseConfig);
|
||||
await result.cleanup();
|
||||
expect(mockCloseAllMcpClients).toHaveBeenCalledWith(result.mcpClients);
|
||||
});
|
||||
|
||||
it('uses queue-based auth when queueConfig is provided', async () => {
|
||||
const queueAuth = {
|
||||
apiKey: 'sk-queue-coder',
|
||||
source: 'profile-api-key' as const,
|
||||
accountId: 'acc-coder',
|
||||
resolvedProvider: 'anthropic' as const,
|
||||
resolvedModelId: 'claude-sonnet-4-5-20250929',
|
||||
reasoningConfig: { type: 'none' as const },
|
||||
};
|
||||
mockResolveAuthFromQueue.mockResolvedValueOnce(queueAuth);
|
||||
|
||||
const result = await createAgentClient({
|
||||
...baseConfig,
|
||||
queueConfig: {
|
||||
queue: [{ id: 'acc-coder' } as unknown as ProviderAccount],
|
||||
requestedModel: 'claude-sonnet-4-5-20250929',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.queueAuth).toBe(queueAuth);
|
||||
expect(mockCreateProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'sk-queue-coder',
|
||||
}),
|
||||
modelId: 'claude-sonnet-4-5-20250929',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when queueConfig provided but no account available', async () => {
|
||||
mockResolveAuthFromQueue.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
createAgentClient({
|
||||
...baseConfig,
|
||||
queueConfig: { queue: [], requestedModel: 'sonnet' },
|
||||
}),
|
||||
).rejects.toThrow('No available account in priority queue');
|
||||
});
|
||||
|
||||
it('merges additionalMcpServers into the required servers list', async () => {
|
||||
mockGetRequiredMcpServers.mockReturnValueOnce(['context7']);
|
||||
|
||||
await createAgentClient({
|
||||
...baseConfig,
|
||||
additionalMcpServers: ['custom-server'],
|
||||
});
|
||||
|
||||
// createMcpClientsForAgent is called because the combined server list is non-empty
|
||||
expect(mockCreateMcpClientsForAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -87,11 +87,11 @@ describe('AGENT_CONFIGS', () => {
|
||||
expect(config.thinkingDefault).toBe('low');
|
||||
});
|
||||
|
||||
it('should configure planner with memory and auto-claude MCP', () => {
|
||||
it('should configure planner with memory and aperant MCP', () => {
|
||||
const config = AGENT_CONFIGS.planner;
|
||||
expect(config.mcpServers).toContain('context7');
|
||||
expect(config.mcpServers).toContain('memory');
|
||||
expect(config.mcpServers).toContain('auto-claude');
|
||||
expect(config.mcpServers).toContain('aperant');
|
||||
expect(config.mcpServersOptional).toContain('linear');
|
||||
expect(config.thinkingDefault).toBe('high');
|
||||
});
|
||||
@@ -197,7 +197,7 @@ describe('mapMcpServerName', () => {
|
||||
expect(mapMcpServerName('graphiti')).toBe('memory');
|
||||
expect(mapMcpServerName('graphiti-memory')).toBe('memory');
|
||||
expect(mapMcpServerName('linear')).toBe('linear');
|
||||
expect(mapMcpServerName('auto-claude')).toBe('auto-claude');
|
||||
expect(mapMcpServerName('aperant')).toBe('aperant');
|
||||
});
|
||||
|
||||
it('should return null for unknown names', () => {
|
||||
@@ -291,12 +291,12 @@ describe('getRequiredMcpServers', () => {
|
||||
expect(servers).toContain('context7');
|
||||
});
|
||||
|
||||
it('should support per-agent MCP removals but never remove auto-claude', () => {
|
||||
it('should support per-agent MCP removals but never remove aperant', () => {
|
||||
const servers = getRequiredMcpServers('coder', {
|
||||
memoryEnabled: true,
|
||||
agentMcpRemove: 'auto-claude,memory',
|
||||
agentMcpRemove: 'aperant,memory',
|
||||
});
|
||||
expect(servers).toContain('auto-claude');
|
||||
expect(servers).toContain('aperant');
|
||||
expect(servers).not.toContain('memory');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,15 +36,15 @@ const ALL_BUILTIN_TOOLS = [...BASE_READ_TOOLS, ...BASE_WRITE_TOOLS, ...WEB_TOOLS
|
||||
const SPEC_TOOLS = [...BASE_READ_TOOLS, 'Write', ...WEB_TOOLS] as const;
|
||||
|
||||
// =============================================================================
|
||||
// Auto-Claude MCP Tools (Custom build management)
|
||||
// Aperant MCP Tools (Custom build management)
|
||||
// =============================================================================
|
||||
|
||||
const TOOL_UPDATE_SUBTASK_STATUS = 'mcp__auto-claude__update_subtask_status';
|
||||
const TOOL_GET_BUILD_PROGRESS = 'mcp__auto-claude__get_build_progress';
|
||||
const TOOL_RECORD_DISCOVERY = 'mcp__auto-claude__record_discovery';
|
||||
const TOOL_RECORD_GOTCHA = 'mcp__auto-claude__record_gotcha';
|
||||
const TOOL_GET_SESSION_CONTEXT = 'mcp__auto-claude__get_session_context';
|
||||
const TOOL_UPDATE_QA_STATUS = 'mcp__auto-claude__update_qa_status';
|
||||
const TOOL_UPDATE_SUBTASK_STATUS = 'mcp__aperant__update_subtask_status';
|
||||
const TOOL_GET_BUILD_PROGRESS = 'mcp__aperant__get_build_progress';
|
||||
const TOOL_RECORD_DISCOVERY = 'mcp__aperant__record_discovery';
|
||||
const TOOL_RECORD_GOTCHA = 'mcp__aperant__record_gotcha';
|
||||
const TOOL_GET_SESSION_CONTEXT = 'mcp__aperant__get_session_context';
|
||||
const TOOL_UPDATE_QA_STATUS = 'mcp__aperant__update_qa_status';
|
||||
|
||||
// =============================================================================
|
||||
// External MCP Tools
|
||||
@@ -246,7 +246,7 @@ export const AGENT_CONFIGS: Record<AgentType, AgentConfig> = {
|
||||
*/
|
||||
build_orchestrator: {
|
||||
tools: [...ALL_BUILTIN_TOOLS, 'SpawnSubagent'],
|
||||
mcpServers: ['context7', 'memory', 'auto-claude'],
|
||||
mcpServers: ['context7', 'memory', 'aperant'],
|
||||
mcpServersOptional: ['linear'],
|
||||
autoClaudeTools: [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
@@ -263,7 +263,7 @@ export const AGENT_CONFIGS: Record<AgentType, AgentConfig> = {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
planner: {
|
||||
tools: [...ALL_BUILTIN_TOOLS],
|
||||
mcpServers: ['context7', 'memory', 'auto-claude'],
|
||||
mcpServers: ['context7', 'memory', 'aperant'],
|
||||
mcpServersOptional: ['linear'],
|
||||
autoClaudeTools: [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
@@ -274,7 +274,7 @@ export const AGENT_CONFIGS: Record<AgentType, AgentConfig> = {
|
||||
},
|
||||
coder: {
|
||||
tools: [...ALL_BUILTIN_TOOLS],
|
||||
mcpServers: ['context7', 'memory', 'auto-claude'],
|
||||
mcpServers: ['context7', 'memory', 'aperant'],
|
||||
mcpServersOptional: ['linear'],
|
||||
autoClaudeTools: [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
@@ -291,7 +291,7 @@ export const AGENT_CONFIGS: Record<AgentType, AgentConfig> = {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
qa_reviewer: {
|
||||
tools: [...ALL_BUILTIN_TOOLS],
|
||||
mcpServers: ['context7', 'memory', 'auto-claude', 'browser'],
|
||||
mcpServers: ['context7', 'memory', 'aperant', 'browser'],
|
||||
mcpServersOptional: ['linear'],
|
||||
autoClaudeTools: [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
@@ -302,7 +302,7 @@ export const AGENT_CONFIGS: Record<AgentType, AgentConfig> = {
|
||||
},
|
||||
qa_fixer: {
|
||||
tools: [...ALL_BUILTIN_TOOLS],
|
||||
mcpServers: ['context7', 'memory', 'auto-claude', 'browser'],
|
||||
mcpServers: ['context7', 'memory', 'aperant', 'browser'],
|
||||
mcpServersOptional: ['linear'],
|
||||
autoClaudeTools: [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
@@ -482,7 +482,7 @@ const MCP_SERVER_NAME_MAP: Record<string, string> = {
|
||||
linear: 'linear',
|
||||
electron: 'electron',
|
||||
puppeteer: 'puppeteer',
|
||||
'auto-claude': 'auto-claude',
|
||||
'aperant': 'aperant',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -593,11 +593,11 @@ export function getRequiredMcpServers(
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-agent MCP removals (never remove auto-claude)
|
||||
// Apply per-agent MCP removals (never remove aperant)
|
||||
if (options.agentMcpRemove) {
|
||||
for (const name of options.agentMcpRemove.split(',')) {
|
||||
const mapped = mapMcpServerName(name.trim(), options.customServerIds);
|
||||
if (mapped && mapped !== 'auto-claude') {
|
||||
if (mapped && mapped !== 'aperant') {
|
||||
const idx = servers.indexOf(mapped);
|
||||
if (idx !== -1) servers.splice(idx, 1);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadProjectIndex(projectDir: string): ProjectIndex {
|
||||
const indexFile = path.join(projectDir, '.auto-claude', 'project_index.json');
|
||||
const indexFile = path.join(projectDir, '.aperant', 'project_index.json');
|
||||
if (fs.existsSync(indexFile)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(indexFile, 'utf8')) as ProjectIndex;
|
||||
|
||||
@@ -14,8 +14,8 @@ import type { FileMatch } from './types.js';
|
||||
/** Directories that should never be searched. */
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
|
||||
'.next', '.nuxt', 'target', 'vendor', '.idea', '.vscode', 'auto-claude',
|
||||
'.auto-claude', '.pytest_cache', '.mypy_cache', 'coverage', '.turbo', '.cache',
|
||||
'.next', '.nuxt', 'target', 'vendor', '.idea', '.vscode',
|
||||
'.aperant', '.pytest_cache', '.mypy_cache', 'coverage', '.turbo', '.cache',
|
||||
'out',
|
||||
]);
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface ServiceInfo {
|
||||
key_directories?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Shape of .auto-claude/project_index.json */
|
||||
/** Shape of .aperant/project_index.json */
|
||||
export interface ProjectIndex {
|
||||
services?: Record<string, ServiceInfo>;
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Tests for MCP Client
|
||||
*
|
||||
* Validates transport creation, client initialization, parallel agent setup,
|
||||
* tool merging, and cleanup behavior.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock @ai-sdk/mcp using inline factory to avoid vi.mock hoisting issues
|
||||
vi.mock('@ai-sdk/mcp', () => ({
|
||||
createMCPClient: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock StdioClientTransport constructor using a proper constructor function
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock constructor
|
||||
StdioClientTransport: vi.fn().mockImplementation(function (this: any) {
|
||||
Object.assign(this, { __kind: 'stdio-transport' });
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock registry to control which servers get resolved
|
||||
vi.mock('../registry', () => ({
|
||||
resolveMcpServers: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock agent-configs to control required servers
|
||||
vi.mock('../../config/agent-configs', () => ({
|
||||
getRequiredMcpServers: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
import { createMCPClient } from '@ai-sdk/mcp';
|
||||
import type { MCPClient } from '@ai-sdk/mcp';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { resolveMcpServers } from '../registry';
|
||||
import { getRequiredMcpServers } from '../../config/agent-configs';
|
||||
import type { McpServerResolveOptions } from '../../config/agent-configs';
|
||||
import {
|
||||
createMcpClient,
|
||||
createMcpClientsForAgent,
|
||||
closeAllMcpClients,
|
||||
mergeMcpTools,
|
||||
} from '../client';
|
||||
import type { McpServerConfig } from '../types';
|
||||
|
||||
const mockCreateMCPClient = vi.mocked(createMCPClient);
|
||||
const mockStdioClientTransport = vi.mocked(StdioClientTransport);
|
||||
const mockResolveMcpServers = vi.mocked(resolveMcpServers);
|
||||
const mockGetRequiredMcpServers = vi.mocked(getRequiredMcpServers);
|
||||
|
||||
// Sentinel: what StdioClientTransport instances look like after construction
|
||||
const FAKE_STDIO_TRANSPORT_PROPS = { __kind: 'stdio-transport' };
|
||||
|
||||
// Helper: build a mock MCP client instance
|
||||
function makeMockMcpInstance(tools = { tool_a: {}, tool_b: {} }) {
|
||||
return {
|
||||
tools: vi.fn().mockResolvedValue(tools),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers: server configs
|
||||
const stdioConfig: McpServerConfig = {
|
||||
id: 'test-stdio',
|
||||
name: 'Test Stdio Server',
|
||||
description: 'A test stdio server',
|
||||
enabledByDefault: true,
|
||||
transport: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'some-mcp-server'],
|
||||
env: { MY_VAR: 'value' },
|
||||
},
|
||||
};
|
||||
|
||||
const httpConfig: McpServerConfig = {
|
||||
id: 'test-http',
|
||||
name: 'Test HTTP Server',
|
||||
description: 'A test streamable-http server',
|
||||
enabledByDefault: true,
|
||||
transport: {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.example.com/sse',
|
||||
headers: { Authorization: 'Bearer token123' },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: StdioClientTransport constructor sets __kind on instance
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock constructor
|
||||
mockStdioClientTransport.mockImplementation(function (this: any) {
|
||||
Object.assign(this, FAKE_STDIO_TRANSPORT_PROPS);
|
||||
} as unknown as typeof StdioClientTransport);
|
||||
// Default: createMCPClient returns a standard mock instance
|
||||
mockCreateMCPClient.mockResolvedValue(makeMockMcpInstance() as unknown as MCPClient);
|
||||
mockGetRequiredMcpServers.mockReturnValue([]);
|
||||
mockResolveMcpServers.mockReturnValue([]);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createMcpClient — transport creation
|
||||
// =============================================================================
|
||||
|
||||
describe('createMcpClient', () => {
|
||||
it('creates a StdioClientTransport for stdio server config', async () => {
|
||||
await createMcpClient(stdioConfig);
|
||||
|
||||
expect(mockStdioClientTransport).toHaveBeenCalledWith({
|
||||
command: 'npx',
|
||||
args: ['-y', 'some-mcp-server'],
|
||||
env: expect.objectContaining({ MY_VAR: 'value' }),
|
||||
cwd: undefined,
|
||||
});
|
||||
// The transport passed to createMCPClient is an instance of the mocked StdioClientTransport
|
||||
expect(mockCreateMCPClient).toHaveBeenCalledWith({
|
||||
transport: expect.objectContaining(FAKE_STDIO_TRANSPORT_PROPS),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an SSE transport object for streamable-http config', async () => {
|
||||
await createMcpClient(httpConfig);
|
||||
|
||||
expect(mockCreateMCPClient).toHaveBeenCalledWith({
|
||||
transport: {
|
||||
type: 'sse',
|
||||
url: 'https://mcp.example.com/sse',
|
||||
headers: { Authorization: 'Bearer token123' },
|
||||
},
|
||||
});
|
||||
// StdioClientTransport should NOT be called for HTTP config
|
||||
expect(mockStdioClientTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a result with serverId, tools, and close function', async () => {
|
||||
const result = await createMcpClient(stdioConfig);
|
||||
|
||||
expect(result.serverId).toBe('test-stdio');
|
||||
expect(result.tools).toEqual({ tool_a: {}, tool_b: {} });
|
||||
expect(typeof result.close).toBe('function');
|
||||
});
|
||||
|
||||
it('merges process.env with server env for stdio transport', async () => {
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = '/usr/bin';
|
||||
|
||||
await createMcpClient(stdioConfig);
|
||||
|
||||
expect(mockStdioClientTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ PATH: '/usr/bin', MY_VAR: 'value' }),
|
||||
}),
|
||||
);
|
||||
|
||||
process.env.PATH = originalPath;
|
||||
});
|
||||
|
||||
it('passes undefined env to StdioClientTransport when no env in config', async () => {
|
||||
const noEnvConfig: McpServerConfig = {
|
||||
...stdioConfig,
|
||||
transport: { type: 'stdio', command: 'node', args: ['server.js'] },
|
||||
};
|
||||
|
||||
await createMcpClient(noEnvConfig);
|
||||
|
||||
expect(mockStdioClientTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('close() delegates to the underlying MCP client close method', async () => {
|
||||
const mockInstance = makeMockMcpInstance();
|
||||
mockCreateMCPClient.mockResolvedValueOnce(mockInstance as unknown as MCPClient);
|
||||
|
||||
const result = await createMcpClient(stdioConfig);
|
||||
await result.close();
|
||||
|
||||
expect(mockInstance.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createMcpClientsForAgent
|
||||
// =============================================================================
|
||||
|
||||
describe('createMcpClientsForAgent', () => {
|
||||
it('returns empty array when agent requires no MCP servers', async () => {
|
||||
mockGetRequiredMcpServers.mockReturnValueOnce([]);
|
||||
mockResolveMcpServers.mockReturnValueOnce([]);
|
||||
|
||||
const clients = await createMcpClientsForAgent('commit_message');
|
||||
|
||||
expect(clients).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates clients for each resolved server config', async () => {
|
||||
mockGetRequiredMcpServers.mockReturnValueOnce(['context7', 'aperant']);
|
||||
mockResolveMcpServers.mockReturnValueOnce([
|
||||
{ ...stdioConfig, id: 'context7' },
|
||||
{ ...stdioConfig, id: 'aperant' },
|
||||
]);
|
||||
// Two separate mock instances for the two servers
|
||||
mockCreateMCPClient
|
||||
.mockResolvedValueOnce(makeMockMcpInstance() as unknown as MCPClient)
|
||||
.mockResolvedValueOnce(makeMockMcpInstance() as unknown as MCPClient);
|
||||
|
||||
const clients = await createMcpClientsForAgent('coder');
|
||||
|
||||
expect(clients).toHaveLength(2);
|
||||
expect(clients[0].serverId).toBe('context7');
|
||||
expect(clients[1].serverId).toBe('aperant');
|
||||
});
|
||||
|
||||
it('skips failed connections without throwing', async () => {
|
||||
mockGetRequiredMcpServers.mockReturnValueOnce(['context7', 'broken-server']);
|
||||
mockResolveMcpServers.mockReturnValueOnce([
|
||||
{ ...stdioConfig, id: 'context7' },
|
||||
{ ...stdioConfig, id: 'broken-server' },
|
||||
]);
|
||||
|
||||
// First call succeeds, second call fails
|
||||
mockCreateMCPClient
|
||||
.mockResolvedValueOnce(makeMockMcpInstance() as unknown as MCPClient)
|
||||
.mockRejectedValueOnce(new Error('connection refused'));
|
||||
|
||||
const clients = await createMcpClientsForAgent('coder');
|
||||
|
||||
// Only the successful client should be returned
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(clients[0].serverId).toBe('context7');
|
||||
});
|
||||
|
||||
it('passes resolveOptions to getRequiredMcpServers', async () => {
|
||||
mockGetRequiredMcpServers.mockReturnValueOnce([]);
|
||||
mockResolveMcpServers.mockReturnValueOnce([]);
|
||||
|
||||
const resolveOptions = { electronMcpEnabled: true };
|
||||
await createMcpClientsForAgent('qa_reviewer', resolveOptions as unknown as McpServerResolveOptions);
|
||||
|
||||
expect(mockGetRequiredMcpServers).toHaveBeenCalledWith('qa_reviewer', resolveOptions);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// mergeMcpTools
|
||||
// =============================================================================
|
||||
|
||||
describe('mergeMcpTools', () => {
|
||||
it('merges tools from multiple clients into a single object', () => {
|
||||
const clients = [
|
||||
{ serverId: 'a', tools: { tool1: {}, tool2: {} }, close: vi.fn() },
|
||||
{ serverId: 'b', tools: { tool3: {}, tool4: {} }, close: vi.fn() },
|
||||
];
|
||||
|
||||
const merged = mergeMcpTools(clients);
|
||||
|
||||
expect(Object.keys(merged)).toHaveLength(4);
|
||||
expect(merged).toHaveProperty('tool1');
|
||||
expect(merged).toHaveProperty('tool3');
|
||||
});
|
||||
|
||||
it('returns empty object for empty clients array', () => {
|
||||
expect(mergeMcpTools([])).toEqual({});
|
||||
});
|
||||
|
||||
it('later client tools overwrite earlier ones on key collision', () => {
|
||||
const clients = [
|
||||
{ serverId: 'a', tools: { shared_tool: { version: 1 } }, close: vi.fn() },
|
||||
{ serverId: 'b', tools: { shared_tool: { version: 2 } }, close: vi.fn() },
|
||||
];
|
||||
|
||||
const merged = mergeMcpTools(clients);
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock property access
|
||||
expect((merged.shared_tool as any).version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// closeAllMcpClients
|
||||
// =============================================================================
|
||||
|
||||
describe('closeAllMcpClients', () => {
|
||||
it('calls close on all clients', async () => {
|
||||
const close1 = vi.fn().mockResolvedValue(undefined);
|
||||
const close2 = vi.fn().mockResolvedValue(undefined);
|
||||
const clients = [
|
||||
{ serverId: 'a', tools: {}, close: close1 },
|
||||
{ serverId: 'b', tools: {}, close: close2 },
|
||||
];
|
||||
|
||||
await closeAllMcpClients(clients);
|
||||
|
||||
expect(close1).toHaveBeenCalled();
|
||||
expect(close2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves even when one client fails to close', async () => {
|
||||
const close1 = vi.fn().mockResolvedValue(undefined);
|
||||
const close2 = vi.fn().mockRejectedValue(new Error('close failed'));
|
||||
const clients = [
|
||||
{ serverId: 'a', tools: {}, close: close1 },
|
||||
{ serverId: 'b', tools: {}, close: close2 },
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
await expect(closeAllMcpClients(clients)).resolves.toBeUndefined();
|
||||
expect(close1).toHaveBeenCalled();
|
||||
expect(close2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves immediately for empty clients array', async () => {
|
||||
await expect(closeAllMcpClients([])).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Tests for MCP Server Registry
|
||||
*
|
||||
* Validates server configuration resolution, required server lookup,
|
||||
* and option-based server filtering.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getMcpServerConfig, resolveMcpServers } from '../registry';
|
||||
|
||||
// =============================================================================
|
||||
// getMcpServerConfig
|
||||
// =============================================================================
|
||||
|
||||
describe('getMcpServerConfig', () => {
|
||||
describe('context7', () => {
|
||||
it('returns the context7 server config', () => {
|
||||
const config = getMcpServerConfig('context7');
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.id).toBe('context7');
|
||||
expect(config?.enabledByDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('uses stdio transport with npx', () => {
|
||||
const config = getMcpServerConfig('context7');
|
||||
expect(config?.transport.type).toBe('stdio');
|
||||
if (config?.transport.type === 'stdio') {
|
||||
expect(config.transport.command).toBe('npx');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('linear', () => {
|
||||
it('returns null when no API key provided', () => {
|
||||
const config = getMcpServerConfig('linear', {});
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('returns config when linearApiKey is provided', () => {
|
||||
const config = getMcpServerConfig('linear', { linearApiKey: 'lin_api_123' });
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.id).toBe('linear');
|
||||
});
|
||||
|
||||
it('returns config when LINEAR_API_KEY is in env option', () => {
|
||||
const config = getMcpServerConfig('linear', { env: { LINEAR_API_KEY: 'lin_env_456' } });
|
||||
expect(config).not.toBeNull();
|
||||
});
|
||||
|
||||
it('injects LINEAR_API_KEY into the transport env', () => {
|
||||
const config = getMcpServerConfig('linear', { linearApiKey: 'lin_inject' });
|
||||
expect(config?.transport.type).toBe('stdio');
|
||||
if (config?.transport.type === 'stdio') {
|
||||
expect(config.transport.env?.LINEAR_API_KEY).toBe('lin_inject');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory', () => {
|
||||
it('returns null when no memory URL provided', () => {
|
||||
const config = getMcpServerConfig('memory', {});
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('returns config with streamable-http transport when URL is provided', () => {
|
||||
const config = getMcpServerConfig('memory', { memoryMcpUrl: 'http://localhost:8080/mcp' });
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.transport.type).toBe('streamable-http');
|
||||
if (config?.transport.type === 'streamable-http') {
|
||||
expect(config.transport.url).toBe('http://localhost:8080/mcp');
|
||||
}
|
||||
});
|
||||
|
||||
it('reads URL from env.GRAPHITI_MCP_URL option', () => {
|
||||
const config = getMcpServerConfig('memory', { env: { GRAPHITI_MCP_URL: 'http://graphiti.local' } });
|
||||
expect(config?.transport.type).toBe('streamable-http');
|
||||
});
|
||||
});
|
||||
|
||||
describe('electron', () => {
|
||||
it('returns the electron server config', () => {
|
||||
const config = getMcpServerConfig('electron');
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.id).toBe('electron');
|
||||
expect(config?.enabledByDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('uses stdio transport', () => {
|
||||
const config = getMcpServerConfig('electron');
|
||||
expect(config?.transport.type).toBe('stdio');
|
||||
});
|
||||
});
|
||||
|
||||
describe('puppeteer', () => {
|
||||
it('returns the puppeteer server config', () => {
|
||||
const config = getMcpServerConfig('puppeteer');
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.id).toBe('puppeteer');
|
||||
});
|
||||
|
||||
it('uses stdio transport', () => {
|
||||
const config = getMcpServerConfig('puppeteer');
|
||||
expect(config?.transport.type).toBe('stdio');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aperant', () => {
|
||||
it('returns aperant config with empty specDir as default', () => {
|
||||
const config = getMcpServerConfig('aperant', {});
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.id).toBe('aperant');
|
||||
});
|
||||
|
||||
it('injects SPEC_DIR into transport env', () => {
|
||||
const config = getMcpServerConfig('aperant', { specDir: '/project/.aperant/specs/001-feature' });
|
||||
expect(config?.transport.type).toBe('stdio');
|
||||
if (config?.transport.type === 'stdio') {
|
||||
expect(config.transport.env?.SPEC_DIR).toBe('/project/.aperant/specs/001-feature');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses node command', () => {
|
||||
const config = getMcpServerConfig('aperant', {});
|
||||
if (config?.transport.type === 'stdio') {
|
||||
expect(config.transport.command).toBe('node');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown server', () => {
|
||||
it('returns null for unrecognized server ID', () => {
|
||||
const config = getMcpServerConfig('nonexistent-server');
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// resolveMcpServers
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveMcpServers', () => {
|
||||
it('returns configs for all recognized server IDs', () => {
|
||||
const configs = resolveMcpServers(['context7', 'electron', 'puppeteer']);
|
||||
expect(configs).toHaveLength(3);
|
||||
expect(configs.map((c) => c.id)).toEqual(['context7', 'electron', 'puppeteer']);
|
||||
});
|
||||
|
||||
it('filters out servers that cannot be configured (e.g. linear without API key)', () => {
|
||||
const configs = resolveMcpServers(['context7', 'linear'], {});
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].id).toBe('context7');
|
||||
});
|
||||
|
||||
it('includes linear when API key option is provided', () => {
|
||||
const configs = resolveMcpServers(['context7', 'linear'], { linearApiKey: 'lin_test' });
|
||||
expect(configs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const configs = resolveMcpServers([]);
|
||||
expect(configs).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips unrecognized server IDs silently', () => {
|
||||
const configs = resolveMcpServers(['context7', 'bogus-server-id']);
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].id).toBe('context7');
|
||||
});
|
||||
|
||||
it('includes memory server when memoryMcpUrl is provided', () => {
|
||||
const configs = resolveMcpServers(['memory'], { memoryMcpUrl: 'http://memory.local' });
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].id).toBe('memory');
|
||||
});
|
||||
|
||||
it('passes specDir through to aperant config', () => {
|
||||
const specDir = '/my-project/.aperant/specs/042-auth';
|
||||
const configs = resolveMcpServers(['aperant'], { specDir });
|
||||
expect(configs).toHaveLength(1);
|
||||
if (configs[0].transport.type === 'stdio') {
|
||||
expect(configs[0].transport.env?.SPEC_DIR).toBe(specDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -100,19 +100,19 @@ const PUPPETEER_SERVER: McpServerConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-Claude MCP server - custom build management tools.
|
||||
* Aperant MCP server - custom build management tools.
|
||||
* Used by planner, coder, and QA agents for build progress tracking.
|
||||
*/
|
||||
function createAutoClaudeServer(specDir: string): McpServerConfig {
|
||||
function createAperantServer(specDir: string): McpServerConfig {
|
||||
return {
|
||||
id: 'auto-claude',
|
||||
name: 'Auto-Claude',
|
||||
id: 'aperant',
|
||||
name: 'Aperant',
|
||||
description: 'Build management tools (progress tracking, session context)',
|
||||
enabledByDefault: true,
|
||||
transport: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['auto-claude-mcp-server.js'],
|
||||
args: ['aperant-mcp-server.js'],
|
||||
env: { SPEC_DIR: specDir },
|
||||
},
|
||||
};
|
||||
@@ -124,7 +124,7 @@ function createAutoClaudeServer(specDir: string): McpServerConfig {
|
||||
|
||||
/** Options for resolving MCP server configurations */
|
||||
export interface McpRegistryOptions {
|
||||
/** Spec directory for auto-claude MCP server */
|
||||
/** Spec directory for aperant MCP server */
|
||||
specDir?: string;
|
||||
/** Memory MCP server URL (if enabled) */
|
||||
memoryMcpUrl?: string;
|
||||
@@ -175,9 +175,9 @@ export function getMcpServerConfig(
|
||||
case 'puppeteer':
|
||||
return PUPPETEER_SERVER;
|
||||
|
||||
case 'auto-claude': {
|
||||
case 'aperant': {
|
||||
const specDir = options.specDir ?? '';
|
||||
return createAutoClaudeServer(specDir);
|
||||
return createAperantServer(specDir);
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -49,7 +49,7 @@ export type McpServerId =
|
||||
| 'memory'
|
||||
| 'electron'
|
||||
| 'puppeteer'
|
||||
| 'auto-claude';
|
||||
| 'aperant';
|
||||
|
||||
/** Configuration for a single MCP server */
|
||||
export interface McpServerConfig {
|
||||
|
||||
@@ -7,11 +7,51 @@
|
||||
* 3. Web app (Next.js SaaS) — pure cloud libSQL
|
||||
*/
|
||||
|
||||
import { createClient } from '@libsql/client';
|
||||
import type { Client } from '@libsql/client';
|
||||
import type { Client, Config } from '@libsql/client/sqlite3';
|
||||
import { createRequire } from 'module';
|
||||
import { join } from 'path';
|
||||
import { MEMORY_SCHEMA_SQL, MEMORY_PRAGMA_SQL } from './schema';
|
||||
|
||||
/**
|
||||
* Lazy-load @libsql/client via CJS require().
|
||||
*
|
||||
* @libsql/client depends on native platform-specific modules (@libsql/darwin-arm64,
|
||||
* @libsql/linux-x64-gnu, etc.). In packaged Electron apps these live in
|
||||
* Resources/node_modules/ (via extraResources). ESM import() can't resolve them
|
||||
* from within app.asar, but CJS require() works because Module.globalPaths is
|
||||
* patched at startup in index.ts to include Resources/node_modules/.
|
||||
*
|
||||
* Using a lazy getter avoids a static import that would crash at startup before
|
||||
* the globalPaths patch runs.
|
||||
*/
|
||||
let _createClient: ((config: Config) => Client) | null = null;
|
||||
|
||||
function loadCreateClient(): (config: Config) => Client {
|
||||
if (!_createClient) {
|
||||
// In Electron: globalThis.require is set up in index.ts with Module.globalPaths
|
||||
// patched to include Resources/node_modules/ for extraResources packages.
|
||||
// In tests/dev: fall back to createRequire (deps are in normal node_modules).
|
||||
const req = globalThis.require ?? createRequire(import.meta.url);
|
||||
let mod: Record<string, unknown>;
|
||||
try {
|
||||
mod = req('@libsql/client/sqlite3');
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to load @libsql/client/sqlite3: ${(err as Error).message}. ` +
|
||||
`Ensure native modules are available in Resources/node_modules/`
|
||||
);
|
||||
}
|
||||
if (typeof mod.createClient !== 'function') {
|
||||
throw new Error(
|
||||
`@libsql/client/sqlite3 did not export createClient (got ${typeof mod.createClient}). ` +
|
||||
`Check that native modules are available in Resources/node_modules/`
|
||||
);
|
||||
}
|
||||
_createClient = mod.createClient as (config: Config) => Client;
|
||||
}
|
||||
return _createClient!;
|
||||
}
|
||||
|
||||
let _client: Client | null = null;
|
||||
|
||||
/**
|
||||
@@ -31,7 +71,7 @@ export async function getMemoryClient(
|
||||
const { app } = await import('electron');
|
||||
const localPath = join(app.getPath('userData'), 'memory.db');
|
||||
|
||||
_client = createClient({
|
||||
_client = loadCreateClient()({
|
||||
url: `file:${localPath}`,
|
||||
...(tursoSyncUrl && authToken
|
||||
? { syncUrl: tursoSyncUrl, authToken, syncInterval: 60 }
|
||||
@@ -78,7 +118,7 @@ export async function getWebMemoryClient(
|
||||
tursoUrl: string,
|
||||
authToken: string,
|
||||
): Promise<Client> {
|
||||
const client = createClient({ url: tursoUrl, authToken });
|
||||
const client = loadCreateClient()({ url: tursoUrl, authToken });
|
||||
|
||||
// Apply PRAGMAs
|
||||
for (const pragma of MEMORY_PRAGMA_SQL.split('\n').filter(l => l.trim())) {
|
||||
@@ -97,7 +137,7 @@ export async function getWebMemoryClient(
|
||||
* Create an in-memory client (for tests — no Electron dependency).
|
||||
*/
|
||||
export async function getInMemoryClient(): Promise<Client> {
|
||||
const client = createClient({ url: ':memory:' });
|
||||
const client = loadCreateClient()({ url: ':memory:' });
|
||||
await client.executeMultiple(MEMORY_SCHEMA_SQL);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export class IncrementalIndexer {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/.auto-claude/**',
|
||||
'**/.aperant/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/.next/**',
|
||||
@@ -313,7 +313,7 @@ export class IncrementalIndexer {
|
||||
private collectSupportedFiles(dir: string, extensions: string[]): string[] {
|
||||
const files: string[] = [];
|
||||
const IGNORED_DIRS = new Set([
|
||||
'node_modules', '.git', '.auto-claude', 'dist', 'build',
|
||||
'node_modules', '.git', '.aperant', 'dist', 'build',
|
||||
'.next', '__pycache__', 'target', '.venv',
|
||||
]);
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ export interface MemoryMethodologyPlugin {
|
||||
|
||||
export const nativePlugin: MemoryMethodologyPlugin = {
|
||||
id: 'native',
|
||||
displayName: 'Auto Claude (Subtasks)',
|
||||
displayName: 'Aperant (Subtasks)',
|
||||
mapPhase: (p: string): UniversalPhase => {
|
||||
const map: Record<string, UniversalPhase> = {
|
||||
planning: 'define',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Manages:
|
||||
* - Baseline capture when worktrees are created
|
||||
* - File content snapshots in .auto-claude/baselines/
|
||||
* - File content snapshots in .aperant/baselines/
|
||||
* - Task modification tracking with semantic analysis
|
||||
* - Persistence of evolution data
|
||||
*/
|
||||
@@ -198,7 +198,7 @@ export class FileEvolutionTracker {
|
||||
storageDir?: string,
|
||||
semanticAnalyzer?: SemanticAnalyzer,
|
||||
) {
|
||||
const resolvedStorageDir = storageDir ?? path.join(projectDir, '.auto-claude');
|
||||
const resolvedStorageDir = storageDir ?? path.join(projectDir, '.aperant');
|
||||
this.storage = new EvolutionStorage(projectDir, resolvedStorageDir);
|
||||
this.analyzer = semanticAnalyzer ?? new SemanticAnalyzer();
|
||||
this.evolutions = this.storage.loadEvolutions();
|
||||
|
||||
@@ -110,8 +110,8 @@ function getFileFromBranch(
|
||||
function findWorktree(projectDir: string, taskId: string): string | undefined {
|
||||
// Common worktree locations
|
||||
const candidates = [
|
||||
path.join(projectDir, '.auto-claude', 'worktrees', taskId),
|
||||
path.join(projectDir, '.auto-claude', 'worktrees', 'tasks', taskId),
|
||||
path.join(projectDir, '.aperant', 'worktrees', taskId),
|
||||
path.join(projectDir, '.aperant', 'worktrees', 'tasks', taskId),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
@@ -254,7 +254,7 @@ export class MergeOrchestrator {
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
this.projectDir = path.resolve(options.projectDir);
|
||||
this.storageDir = options.storageDir ?? path.join(this.projectDir, '.auto-claude');
|
||||
this.storageDir = options.storageDir ?? path.join(this.projectDir, '.aperant');
|
||||
this.enableAi = options.enableAi ?? true;
|
||||
this.dryRun = options.dryRun ?? false;
|
||||
this.aiResolver = options.aiResolver;
|
||||
|
||||
@@ -313,7 +313,7 @@ function getCommitInfo(commitHash: string, cwd: string): Record<string, string>
|
||||
|
||||
function getWorktreeFileContent(taskId: string, filePath: string, projectDir: string): string {
|
||||
// Try common worktree locations
|
||||
const worktreePath = path.join(projectDir, '.auto-claude', 'worktrees', taskId, filePath);
|
||||
const worktreePath = path.join(projectDir, '.aperant', 'worktrees', taskId, filePath);
|
||||
if (fs.existsSync(worktreePath)) {
|
||||
try {
|
||||
return fs.readFileSync(worktreePath, 'utf8');
|
||||
@@ -369,7 +369,7 @@ export class FileTimelineTracker {
|
||||
|
||||
constructor(projectPath: string, storagePath?: string) {
|
||||
this.projectPath = path.resolve(projectPath);
|
||||
const resolvedStoragePath = storagePath ?? path.join(this.projectPath, '.auto-claude');
|
||||
const resolvedStoragePath = storagePath ?? path.join(this.projectPath, '.aperant');
|
||||
this.persistence = new TimelinePersistence(resolvedStoragePath);
|
||||
this.timelines = this.persistence.loadAllTimelines();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { executeParallel } from '../parallel-executor';
|
||||
import type { ParallelExecutorConfig, SubtaskSessionRunner } from '../parallel-executor';
|
||||
import type { SubtaskInfo } from '../build-orchestrator';
|
||||
import type { SessionResult } from '../../session/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSubtask(id: string): SubtaskInfo {
|
||||
return {
|
||||
id,
|
||||
description: `Subtask ${id}`,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
function makeResult(outcome: SessionResult['outcome']): SessionResult {
|
||||
return {
|
||||
outcome,
|
||||
error: outcome === 'error' ? new Error('session error') : undefined,
|
||||
totalSteps: 1,
|
||||
lastMessage: '',
|
||||
} as unknown as SessionResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: run executeParallel with fake timers advanced automatically
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runWithFakeTimers<T>(fn: () => Promise<T>): Promise<T> {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const promise = fn();
|
||||
await vi.runAllTimersAsync();
|
||||
return await promise;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('executeParallel', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty task list
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns empty results for an empty subtask list', async () => {
|
||||
const runner = vi.fn() as unknown as SubtaskSessionRunner;
|
||||
const result = await executeParallel([], runner);
|
||||
|
||||
expect(result.results).toHaveLength(0);
|
||||
expect(result.successCount).toBe(0);
|
||||
expect(result.failureCount).toBe(0);
|
||||
expect(result.rateLimitedCount).toBe(0);
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// All succeed
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns successCount equal to number of subtasks when all succeed', async () => {
|
||||
const subtasks = [makeSubtask('t1'), makeSubtask('t2'), makeSubtask('t3')];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, { maxConcurrency: 10 }),
|
||||
);
|
||||
|
||||
expect(result.successCount).toBe(3);
|
||||
expect(result.failureCount).toBe(0);
|
||||
expect(result.rateLimitedCount).toBe(0);
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.results).toHaveLength(3);
|
||||
for (const r of result.results) {
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.rateLimited).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps subtaskIds correctly in results', async () => {
|
||||
const subtasks = [makeSubtask('alpha'), makeSubtask('beta')];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, { maxConcurrency: 10 }),
|
||||
);
|
||||
const ids = result.results.map((r) => r.subtaskId);
|
||||
|
||||
expect(ids).toContain('alpha');
|
||||
expect(ids).toContain('beta');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Partial failure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('handles partial failure — some succeed, some fail', async () => {
|
||||
const subtasks = [makeSubtask('s1'), makeSubtask('s2'), makeSubtask('s3')];
|
||||
|
||||
const runner = vi.fn()
|
||||
.mockResolvedValueOnce(makeResult('completed'))
|
||||
.mockResolvedValueOnce(makeResult('error'))
|
||||
.mockResolvedValueOnce(makeResult('completed')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, { maxConcurrency: 10 }),
|
||||
);
|
||||
|
||||
expect(result.successCount).toBe(2);
|
||||
expect(result.failureCount).toBe(1);
|
||||
expect(result.rateLimitedCount).toBe(0);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// All fail
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('handles all-fail scenario gracefully', async () => {
|
||||
const subtasks = [makeSubtask('f1'), makeSubtask('f2')];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('error')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, { maxConcurrency: 10 }),
|
||||
);
|
||||
|
||||
expect(result.successCount).toBe(0);
|
||||
expect(result.failureCount).toBe(2);
|
||||
expect(result.cancelled).toBe(false);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rate limiting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('tracks rate-limited subtasks separately', async () => {
|
||||
const subtasks = [makeSubtask('r1'), makeSubtask('r2')];
|
||||
|
||||
const runner = vi.fn()
|
||||
.mockResolvedValueOnce(makeResult('rate_limited'))
|
||||
.mockResolvedValueOnce(makeResult('completed')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, { maxConcurrency: 10 }),
|
||||
);
|
||||
|
||||
expect(result.rateLimitedCount).toBe(1);
|
||||
expect(result.successCount).toBe(1);
|
||||
});
|
||||
|
||||
it('calls onRateLimited callback when rate-limited result is detected in first batch', async () => {
|
||||
// Single-item batches (maxConcurrency=1) so back-off delay fires between batches
|
||||
const subtasks = [makeSubtask('rl1'), makeSubtask('rl2')];
|
||||
|
||||
const runner = vi.fn()
|
||||
.mockResolvedValueOnce(makeResult('rate_limited'))
|
||||
.mockResolvedValueOnce(makeResult('completed')) as SubtaskSessionRunner;
|
||||
|
||||
const onRateLimited = vi.fn();
|
||||
const config: ParallelExecutorConfig = { maxConcurrency: 1, onRateLimited };
|
||||
|
||||
await runWithFakeTimers(() => executeParallel(subtasks, runner, config));
|
||||
|
||||
expect(onRateLimited).toHaveBeenCalledWith(expect.any(Number));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Concurrency limit batching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('respects maxConcurrency and processes all tasks in batches', async () => {
|
||||
const subtasks = [
|
||||
makeSubtask('b1'), makeSubtask('b2'), makeSubtask('b3'),
|
||||
makeSubtask('b4'), makeSubtask('b5'),
|
||||
];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, { maxConcurrency: 3 }),
|
||||
);
|
||||
|
||||
expect(result.successCount).toBe(5);
|
||||
expect(result.results).toHaveLength(5);
|
||||
expect(runner).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Callbacks — onSubtaskStart / onSubtaskComplete / onSubtaskFailed
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('calls onSubtaskStart for each subtask', async () => {
|
||||
const subtasks = [makeSubtask('c1'), makeSubtask('c2')];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner;
|
||||
const onSubtaskStart = vi.fn();
|
||||
|
||||
await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, { maxConcurrency: 10, onSubtaskStart }),
|
||||
);
|
||||
|
||||
expect(onSubtaskStart).toHaveBeenCalledTimes(2);
|
||||
expect(onSubtaskStart).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' }));
|
||||
expect(onSubtaskStart).toHaveBeenCalledWith(expect.objectContaining({ id: 'c2' }));
|
||||
});
|
||||
|
||||
it('calls onSubtaskComplete for successful subtasks — single task (no stagger)', async () => {
|
||||
const subtasks = [makeSubtask('ok1')];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner;
|
||||
const onSubtaskComplete = vi.fn();
|
||||
|
||||
// Single item at index 0 → stagger = 0ms → no fake timers needed
|
||||
const result = await executeParallel(subtasks, runner, { maxConcurrency: 1, onSubtaskComplete });
|
||||
|
||||
expect(onSubtaskComplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'ok1' }),
|
||||
expect.objectContaining({ outcome: 'completed' }),
|
||||
);
|
||||
expect(result.successCount).toBe(1);
|
||||
});
|
||||
|
||||
it('calls onSubtaskFailed for error outcomes — single task', async () => {
|
||||
const subtasks = [makeSubtask('fail1')];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('error')) as SubtaskSessionRunner;
|
||||
const onSubtaskFailed = vi.fn();
|
||||
|
||||
const result = await executeParallel(subtasks, runner, { maxConcurrency: 1, onSubtaskFailed });
|
||||
|
||||
expect(onSubtaskFailed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'fail1' }),
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(result.failureCount).toBe(1);
|
||||
});
|
||||
|
||||
it('calls onSubtaskFailed when runner throws — single task', async () => {
|
||||
const subtasks = [makeSubtask('throw1')];
|
||||
const runner = vi.fn().mockRejectedValue(new Error('Unexpected crash')) as SubtaskSessionRunner;
|
||||
const onSubtaskFailed = vi.fn();
|
||||
|
||||
const result = await executeParallel(subtasks, runner, { maxConcurrency: 1, onSubtaskFailed });
|
||||
|
||||
expect(result.failureCount).toBe(1);
|
||||
expect(onSubtaskFailed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'throw1' }),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cancellation via AbortSignal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('marks cancelled=true when aborted before execution starts', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const subtasks = [makeSubtask('x1'), makeSubtask('x2')];
|
||||
const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, {
|
||||
maxConcurrency: 10,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns cancelled=true when aborted after first batch completes', async () => {
|
||||
const controller = new AbortController();
|
||||
const subtasks = [makeSubtask('a1'), makeSubtask('a2')];
|
||||
|
||||
const runner = vi.fn().mockImplementation(async (subtask: SubtaskInfo) => {
|
||||
if (subtask.id === 'a1') {
|
||||
controller.abort();
|
||||
}
|
||||
return makeResult('completed');
|
||||
}) as SubtaskSessionRunner;
|
||||
|
||||
const result = await runWithFakeTimers(() =>
|
||||
executeParallel(subtasks, runner, {
|
||||
maxConcurrency: 1,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rate-limited error from thrown exception — single task, no stagger
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('marks rateLimited=true when thrown error contains 429', async () => {
|
||||
const subtasks = [makeSubtask('rl-throw')];
|
||||
const runner = vi.fn().mockRejectedValue(new Error('HTTP 429 too many requests')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await executeParallel(subtasks, runner, { maxConcurrency: 1 });
|
||||
|
||||
expect(result.results[0].rateLimited).toBe(true);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Result structure — single task, no stagger
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('includes session result in ParallelSubtaskResult when session ran', async () => {
|
||||
const subtasks = [makeSubtask('struct1')];
|
||||
const sessionResult = makeResult('completed');
|
||||
const runner = vi.fn().mockResolvedValue(sessionResult) as SubtaskSessionRunner;
|
||||
|
||||
const result = await executeParallel(subtasks, runner);
|
||||
|
||||
expect(result.results[0].result).toBeDefined();
|
||||
expect(result.results[0].result?.outcome).toBe('completed');
|
||||
});
|
||||
|
||||
it('includes error string when runner throws', async () => {
|
||||
const subtasks = [makeSubtask('err-str')];
|
||||
const runner = vi.fn().mockRejectedValue(new Error('crash detail')) as SubtaskSessionRunner;
|
||||
|
||||
const result = await executeParallel(subtasks, runner);
|
||||
|
||||
expect(result.results[0].error).toContain('crash detail');
|
||||
expect(result.results[0].success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
const mockWriteFile = vi.fn();
|
||||
const mockUnlink = vi.fn();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: (...args: unknown[]) => mockReadFile(...args),
|
||||
writeFile: (...args: unknown[]) => mockWriteFile(...args),
|
||||
unlink: (...args: unknown[]) => mockUnlink(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/json-repair', () => ({
|
||||
safeParseJson: (raw: string) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../qa-reports', () => ({
|
||||
generateQAReport: vi.fn(() => '# QA Report'),
|
||||
generateEscalationReport: vi.fn(() => '# Escalation Report'),
|
||||
generateManualTestPlan: vi.fn().mockResolvedValue('# Manual Test Plan'),
|
||||
}));
|
||||
|
||||
// qa-loop.ts imports from '../schema' (relative to orchestration/)
|
||||
// which resolves to src/main/ai/schema/index.ts
|
||||
vi.mock('../../schema', () => ({
|
||||
QASignoffSchema: {},
|
||||
validateStructuredOutput: vi.fn((_data: unknown, _schema: unknown) => ({
|
||||
valid: true,
|
||||
data: _data,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { QALoop } from '../qa-loop';
|
||||
import type { QALoopConfig, QASessionRunConfig } from '../qa-loop';
|
||||
import type { SessionResult } from '../../session/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPEC_DIR = '/project/.aperant/specs/001-feature';
|
||||
const PROJECT_DIR = '/project';
|
||||
|
||||
function completedPlan(qaStatus?: 'approved' | 'rejected' | 'unknown') {
|
||||
const plan: Record<string, unknown> = {
|
||||
phases: [
|
||||
{ subtasks: [{ status: 'completed' }, { status: 'completed' }] },
|
||||
],
|
||||
};
|
||||
|
||||
if (qaStatus === 'approved') {
|
||||
plan.qa_signoff = { status: 'approved', issues_found: [] };
|
||||
} else if (qaStatus === 'rejected') {
|
||||
plan.qa_signoff = { status: 'rejected', issues_found: [{ title: 'Test failure', type: 'critical' }] };
|
||||
}
|
||||
// qaStatus === 'unknown' → no qa_signoff key
|
||||
|
||||
return JSON.stringify(plan);
|
||||
}
|
||||
|
||||
function makeSessionResult(outcome: SessionResult['outcome']): SessionResult {
|
||||
return {
|
||||
outcome,
|
||||
error: outcome === 'error' ? new Error('session error') : undefined,
|
||||
totalSteps: 1,
|
||||
lastMessage: '',
|
||||
} as unknown as SessionResult;
|
||||
}
|
||||
|
||||
function makeConfig(overrides: Partial<QALoopConfig> = {}): QALoopConfig {
|
||||
return {
|
||||
specDir: SPEC_DIR,
|
||||
projectDir: PROJECT_DIR,
|
||||
maxIterations: 5,
|
||||
generatePrompt: vi.fn().mockResolvedValue('system prompt'),
|
||||
runSession: vi.fn().mockResolvedValue(makeSessionResult('completed')),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('QALoop', () => {
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
mockWriteFile.mockReset().mockResolvedValue(undefined);
|
||||
mockUnlink.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Build completeness guard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns error outcome when build is not complete', async () => {
|
||||
// Plan with a non-completed subtask
|
||||
const plan = JSON.stringify({
|
||||
phases: [{ subtasks: [{ status: 'pending' }] }],
|
||||
});
|
||||
|
||||
// No QA_FIX_REQUEST.md either
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) return Promise.resolve(plan);
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig();
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(false);
|
||||
expect(outcome.reason).toBe('error');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Already approved
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns approved immediately when QA signoff is already "approved"', async () => {
|
||||
const plan = completedPlan('approved');
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) return Promise.resolve(plan);
|
||||
// QA_FIX_REQUEST.md does not exist
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig();
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(true);
|
||||
expect(outcome.totalIterations).toBe(0);
|
||||
// runSession should NOT have been called (short-circuit)
|
||||
expect(config.runSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// QA approved on first iteration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('approves on the first iteration when reviewer returns approved', async () => {
|
||||
// Let the reviewer run session set the approved state, then all subsequent reads return approved
|
||||
let sessionCallCount = 0;
|
||||
let _planReadCount = 0;
|
||||
|
||||
const runSession = vi.fn().mockImplementation(async () => {
|
||||
sessionCallCount++;
|
||||
return makeSessionResult('completed');
|
||||
});
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
_planReadCount++;
|
||||
// Before the reviewer has run, return no signoff (build complete, no qa yet)
|
||||
if (sessionCallCount === 0) return Promise.resolve(completedPlan());
|
||||
// After the reviewer ran, return approved
|
||||
return Promise.resolve(completedPlan('approved'));
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig({ runSession, maxIterations: 5 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(true);
|
||||
// Should have approved within the first few iterations
|
||||
expect(outcome.totalIterations).toBeGreaterThanOrEqual(1);
|
||||
// Only the reviewer should have been called (no fixer needed)
|
||||
const calls = runSession.mock.calls as Array<[QASessionRunConfig]>;
|
||||
expect(calls.every((c) => c[0].agentType === 'qa_reviewer')).toBe(true);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rejected then approved on retry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('runs fixer then approves on second iteration', async () => {
|
||||
// Track how many times runSession has been called so we know which "phase" we're in
|
||||
let sessionCallCount = 0;
|
||||
let planReadCount = 0;
|
||||
|
||||
const runSession = vi.fn().mockImplementation(async () => {
|
||||
sessionCallCount++;
|
||||
return makeSessionResult('completed');
|
||||
});
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
planReadCount++;
|
||||
if (planReadCount === 1) return Promise.resolve(completedPlan()); // isBuildComplete
|
||||
// Reviewer on iteration 1 ran when sessionCallCount >= 1
|
||||
// Serve rejected until fixer has run (sessionCallCount >= 2), then approved
|
||||
if (sessionCallCount < 2) {
|
||||
return Promise.resolve(completedPlan('rejected'));
|
||||
}
|
||||
return Promise.resolve(completedPlan('approved'));
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig({ runSession, maxIterations: 5 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(true);
|
||||
// At minimum: reviewer (iter 1) + fixer + reviewer (iter 2) = 3
|
||||
expect(sessionCallCount).toBeGreaterThanOrEqual(3);
|
||||
const calls = runSession.mock.calls as Array<[QASessionRunConfig]>;
|
||||
const agentTypes = calls.map((c) => c[0].agentType);
|
||||
expect(agentTypes).toContain('qa_reviewer');
|
||||
expect(agentTypes).toContain('qa_fixer');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Max iterations reached
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns max_iterations when approval is never reached', async () => {
|
||||
// Always return "rejected" status with a unique issue each time
|
||||
// so recurring_issues threshold is never reached within maxIterations=2
|
||||
let planReadCount = 0;
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
planReadCount++;
|
||||
if (planReadCount === 1) return Promise.resolve(completedPlan()); // build complete check
|
||||
|
||||
// Return distinct issues each time to avoid recurring_issues escalation
|
||||
const plan = JSON.stringify({
|
||||
phases: [{ subtasks: [{ status: 'completed' }] }],
|
||||
qa_signoff: {
|
||||
status: 'rejected',
|
||||
issues_found: [{ title: `Unique issue ${planReadCount}`, type: 'warning' }],
|
||||
},
|
||||
});
|
||||
return Promise.resolve(plan);
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig({ maxIterations: 2 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(false);
|
||||
expect(outcome.reason).toBe('max_iterations');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Consecutive error escalation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('escalates after MAX_CONSECUTIVE_ERRORS (3) consecutive unknown status responses', async () => {
|
||||
let planReadCount = 0;
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
planReadCount++;
|
||||
if (planReadCount === 1) return Promise.resolve(completedPlan()); // build complete
|
||||
// Return a plan with no qa_signoff — "unknown" status
|
||||
const planWithNoSignoff = JSON.stringify({
|
||||
phases: [{ subtasks: [{ status: 'completed' }] }],
|
||||
});
|
||||
return Promise.resolve(planWithNoSignoff);
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig({ maxIterations: 10 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(false);
|
||||
expect(outcome.reason).toBe('consecutive_errors');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recurring issue detection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('escalates when the same issue recurs 3 or more times', async () => {
|
||||
const recurringIssue = { title: 'Null pointer exception', type: 'critical' as const };
|
||||
const rejectedPlan = JSON.stringify({
|
||||
phases: [{ subtasks: [{ status: 'completed' }] }],
|
||||
qa_signoff: { status: 'rejected', issues_found: [recurringIssue] },
|
||||
});
|
||||
|
||||
let planReadCount = 0;
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
planReadCount++;
|
||||
if (planReadCount === 1) return Promise.resolve(completedPlan()); // build complete
|
||||
return Promise.resolve(rejectedPlan);
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig({ maxIterations: 10 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(false);
|
||||
expect(outcome.reason).toBe('recurring_issues');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cancellation via AbortSignal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns cancelled outcome when aborted before first iteration runs', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) return Promise.resolve(completedPlan());
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig({ abortSignal: controller.signal, maxIterations: 5 });
|
||||
const loop = new QALoop(config);
|
||||
|
||||
// Abort after construction so the event listener fires
|
||||
controller.abort();
|
||||
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(false);
|
||||
expect(outcome.reason).toBe('cancelled');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fixer error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns error outcome when fixer session fails', async () => {
|
||||
let planReadCount = 0;
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
planReadCount++;
|
||||
if (planReadCount === 1) return Promise.resolve(completedPlan());
|
||||
return Promise.resolve(completedPlan('rejected'));
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const runSession = vi.fn()
|
||||
.mockResolvedValueOnce(makeSessionResult('completed')) // reviewer iteration 1
|
||||
.mockResolvedValueOnce(makeSessionResult('error')); // fixer fails
|
||||
|
||||
const config = makeConfig({ runSession, maxIterations: 5 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(false);
|
||||
expect(outcome.reason).toBe('error');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reviewer cancelled mid-loop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns cancelled when reviewer session is cancelled', async () => {
|
||||
let planReadCount = 0;
|
||||
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
planReadCount++;
|
||||
if (planReadCount === 1) return Promise.resolve(completedPlan());
|
||||
return Promise.resolve(completedPlan());
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const runSession = vi.fn().mockResolvedValueOnce(makeSessionResult('cancelled'));
|
||||
|
||||
const config = makeConfig({ runSession, maxIterations: 5 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
expect(outcome.approved).toBe(false);
|
||||
expect(outcome.reason).toBe('cancelled');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Human feedback processing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('processes QA_FIX_REQUEST.md before running the review loop', async () => {
|
||||
// QA_FIX_REQUEST.md exists
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('QA_FIX_REQUEST.md')) return Promise.resolve('Fix this please');
|
||||
if (path.endsWith('implementation_plan.json')) return Promise.resolve(completedPlan('approved'));
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const runSession = vi.fn().mockResolvedValue(makeSessionResult('completed'));
|
||||
const config = makeConfig({ runSession, maxIterations: 5 });
|
||||
const loop = new QALoop(config);
|
||||
const outcome = await loop.run();
|
||||
|
||||
// Fixer should have been invoked for human feedback
|
||||
const calls = runSession.mock.calls as Array<[QASessionRunConfig]>;
|
||||
expect(calls.some((c) => c[0].agentType === 'qa_fixer')).toBe(true);
|
||||
// Fix request file should be deleted
|
||||
expect(mockUnlink).toHaveBeenCalledWith(path.join(SPEC_DIR, 'QA_FIX_REQUEST.md'));
|
||||
// Overall outcome should still reflect the QA result
|
||||
expect(outcome.approved).toBe(true);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Events
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('emits qa-complete event with the final outcome', async () => {
|
||||
let planReadCount = 0;
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (path.endsWith('implementation_plan.json')) {
|
||||
planReadCount++;
|
||||
if (planReadCount === 1) return Promise.resolve(completedPlan());
|
||||
return Promise.resolve(completedPlan('approved'));
|
||||
}
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const config = makeConfig();
|
||||
const loop = new QALoop(config);
|
||||
|
||||
const completedEvents: unknown[] = [];
|
||||
loop.on('qa-complete', (outcome) => completedEvents.push(outcome));
|
||||
|
||||
await loop.run();
|
||||
|
||||
expect(completedEvents).toHaveLength(1);
|
||||
expect((completedEvents[0] as { approved: boolean }).approved).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockReaddirSync = vi.fn();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: (...args: unknown[]) => mockReadFile(...args),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
generateQAReport,
|
||||
generateEscalationReport,
|
||||
generateManualTestPlan,
|
||||
issuesSimilar,
|
||||
isNoTestProject,
|
||||
} from '../qa-reports';
|
||||
import type { QAIterationRecord, QAIssue } from '../qa-loop';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeRecord(
|
||||
iteration: number,
|
||||
status: 'approved' | 'rejected' | 'error',
|
||||
issues: QAIssue[] = [],
|
||||
durationMs = 1000,
|
||||
): QAIterationRecord {
|
||||
return {
|
||||
iteration,
|
||||
status,
|
||||
issues,
|
||||
durationMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeIssue(title: string, opts: Partial<QAIssue> = {}): QAIssue {
|
||||
return { title, ...opts };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateQAReport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('generateQAReport', () => {
|
||||
it('produces a report with APPROVED status label', () => {
|
||||
const iterations: QAIterationRecord[] = [
|
||||
makeRecord(1, 'rejected', [makeIssue('Missing test')], 2000),
|
||||
makeRecord(2, 'approved', [], 1500),
|
||||
];
|
||||
|
||||
const report = generateQAReport(iterations, 'approved');
|
||||
|
||||
expect(report).toContain('APPROVED');
|
||||
expect(report).toContain('PASSED');
|
||||
expect(report).toContain('Total Iterations');
|
||||
expect(report).toContain('2');
|
||||
});
|
||||
|
||||
it('produces a report with ESCALATED status label', () => {
|
||||
const iterations: QAIterationRecord[] = [
|
||||
makeRecord(1, 'rejected', [makeIssue('Null pointer')], 500),
|
||||
];
|
||||
|
||||
const report = generateQAReport(iterations, 'escalated');
|
||||
|
||||
expect(report).toContain('ESCALATED');
|
||||
expect(report).toContain('FAILED');
|
||||
expect(report).toContain('escalated to human review');
|
||||
});
|
||||
|
||||
it('produces a report with MAX ITERATIONS REACHED label', () => {
|
||||
const iterations: QAIterationRecord[] = [
|
||||
makeRecord(1, 'rejected', [], 800),
|
||||
makeRecord(2, 'rejected', [], 800),
|
||||
];
|
||||
|
||||
const report = generateQAReport(iterations, 'max_iterations');
|
||||
|
||||
expect(report).toContain('MAX ITERATIONS REACHED');
|
||||
expect(report).toContain('FAILED');
|
||||
expect(report).toContain('maximum');
|
||||
});
|
||||
|
||||
it('handles empty iteration history gracefully', () => {
|
||||
const report = generateQAReport([], 'approved');
|
||||
|
||||
expect(report).toContain('No iterations recorded');
|
||||
expect(report).toContain('Total Iterations');
|
||||
});
|
||||
|
||||
it('includes issue details in iteration history section', () => {
|
||||
const issue = makeIssue('Type error in auth.ts', {
|
||||
type: 'critical',
|
||||
location: 'src/auth.ts:42',
|
||||
description: 'Property does not exist',
|
||||
fix_required: 'Add null check',
|
||||
});
|
||||
|
||||
const report = generateQAReport([makeRecord(1, 'rejected', [issue])], 'escalated');
|
||||
|
||||
expect(report).toContain('Type error in auth.ts');
|
||||
expect(report).toContain('[CRITICAL]');
|
||||
expect(report).toContain('src/auth.ts:42');
|
||||
expect(report).toContain('Property does not exist');
|
||||
expect(report).toContain('Add null check');
|
||||
});
|
||||
|
||||
it('calculates summary counts correctly', () => {
|
||||
const iterations: QAIterationRecord[] = [
|
||||
makeRecord(1, 'rejected', [makeIssue('A'), makeIssue('B')]),
|
||||
makeRecord(2, 'error', [makeIssue('C')]),
|
||||
makeRecord(3, 'approved', []),
|
||||
];
|
||||
|
||||
const report = generateQAReport(iterations, 'approved');
|
||||
|
||||
expect(report).toContain('Approved Iterations');
|
||||
expect(report).toContain('Rejected Iterations');
|
||||
expect(report).toContain('Error Iterations');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateEscalationReport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('generateEscalationReport', () => {
|
||||
it('lists recurring issues by title', () => {
|
||||
const recurringIssues: QAIssue[] = [
|
||||
makeIssue('Database connection leak', {
|
||||
type: 'critical',
|
||||
location: 'src/db.ts',
|
||||
description: 'Connection is never closed',
|
||||
fix_required: 'Use try-finally block',
|
||||
}),
|
||||
];
|
||||
|
||||
const iterations: QAIterationRecord[] = [
|
||||
makeRecord(1, 'rejected', recurringIssues),
|
||||
makeRecord(2, 'rejected', recurringIssues),
|
||||
makeRecord(3, 'rejected', recurringIssues),
|
||||
];
|
||||
|
||||
const report = generateEscalationReport(iterations, recurringIssues);
|
||||
|
||||
expect(report).toContain('Human Intervention Required');
|
||||
expect(report).toContain('Database connection leak');
|
||||
expect(report).toContain('src/db.ts');
|
||||
expect(report).toContain('Connection is never closed');
|
||||
expect(report).toContain('Use try-finally block');
|
||||
});
|
||||
|
||||
it('includes summary statistics', () => {
|
||||
const issue = makeIssue('Error X');
|
||||
const iterations = [
|
||||
makeRecord(1, 'rejected', [issue]),
|
||||
makeRecord(2, 'rejected', [issue]),
|
||||
makeRecord(3, 'rejected', [issue]),
|
||||
];
|
||||
|
||||
const report = generateEscalationReport(iterations, [issue]);
|
||||
|
||||
expect(report).toContain('Total QA Iterations');
|
||||
expect(report).toContain('Total Issues Found');
|
||||
expect(report).toContain('Unique Issues');
|
||||
expect(report).toContain('Fix Success Rate');
|
||||
});
|
||||
|
||||
it('includes recommended actions section', () => {
|
||||
const report = generateEscalationReport([], []);
|
||||
|
||||
expect(report).toContain('Recommended Actions');
|
||||
expect(report).toContain('QA_FIX_REQUEST.md');
|
||||
});
|
||||
|
||||
it('includes most common issues when present', () => {
|
||||
const issue1 = makeIssue('Common bug');
|
||||
const issue2 = makeIssue('Rare bug');
|
||||
|
||||
const iterations = [
|
||||
makeRecord(1, 'rejected', [issue1, issue2]),
|
||||
makeRecord(2, 'rejected', [issue1]),
|
||||
makeRecord(3, 'rejected', [issue1]),
|
||||
];
|
||||
|
||||
const report = generateEscalationReport(iterations, [issue1]);
|
||||
|
||||
expect(report).toContain('Most Common Issues');
|
||||
expect(report).toContain('common bug');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateManualTestPlan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('generateManualTestPlan', () => {
|
||||
const SPEC_DIR = '/project/.aperant/specs/001-feature';
|
||||
const PROJECT_DIR = '/project';
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
mockExistsSync.mockReset().mockReturnValue(false);
|
||||
mockReaddirSync.mockReset().mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('generates a basic test plan when spec.md is missing', async () => {
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR);
|
||||
|
||||
expect(plan).toContain('Manual Test Plan');
|
||||
expect(plan).toContain('Pre-Test Setup');
|
||||
expect(plan).toContain('Functional Tests');
|
||||
expect(plan).toContain('Sign-off');
|
||||
});
|
||||
|
||||
it('extracts acceptance criteria from spec.md when available', async () => {
|
||||
const specContent = `# Feature Spec
|
||||
|
||||
## Overview
|
||||
Some description.
|
||||
|
||||
## Acceptance Criteria
|
||||
- User can log in
|
||||
- User sees dashboard after login
|
||||
- Invalid credentials show error
|
||||
|
||||
## Technical Details
|
||||
Not relevant here.
|
||||
`;
|
||||
|
||||
mockReadFile.mockResolvedValue(specContent);
|
||||
|
||||
const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR);
|
||||
|
||||
expect(plan).toContain('User can log in');
|
||||
expect(plan).toContain('User sees dashboard after login');
|
||||
expect(plan).toContain('Invalid credentials show error');
|
||||
});
|
||||
|
||||
it('notes "no automated test framework" when none is detected', async () => {
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||
// existsSync returns false → no test config found
|
||||
|
||||
const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR);
|
||||
|
||||
expect(plan).toContain('No automated test framework detected');
|
||||
});
|
||||
|
||||
it('notes "supplemental manual verification" when a test framework is present', async () => {
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||
// Simulate vitest.config.ts existing
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith('vitest.config.ts'));
|
||||
|
||||
const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR);
|
||||
|
||||
expect(plan).toContain('supplement to automated tests');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// issuesSimilar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('issuesSimilar', () => {
|
||||
it('returns true for identical issues', () => {
|
||||
const issue = makeIssue('Null pointer exception', { description: 'Null reference in auth module' });
|
||||
expect(issuesSimilar(issue, issue)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for issues with high token overlap', () => {
|
||||
const a = makeIssue('null pointer exception in auth module');
|
||||
const b = makeIssue('null pointer exception in auth module');
|
||||
expect(issuesSimilar(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for completely different issues', () => {
|
||||
const a = makeIssue('Database connection timeout', { description: 'MySQL connection drops after 30s' });
|
||||
const b = makeIssue('UI button not rendering', { description: 'Submit button disappears on mobile' });
|
||||
expect(issuesSimilar(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it('strips common prefixes before comparing', () => {
|
||||
const a = makeIssue('error: null pointer exception');
|
||||
const b = makeIssue('bug: null pointer exception');
|
||||
// Both strip to "null pointer exception" — should be considered similar
|
||||
expect(issuesSimilar(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses custom threshold when provided', () => {
|
||||
const a = makeIssue('Some issue here', { description: 'partial match description' });
|
||||
const b = makeIssue('Some issue here', { description: 'completely different thing' });
|
||||
// At very low threshold, should match on title alone
|
||||
expect(issuesSimilar(a, b, 0.1)).toBe(true);
|
||||
// At very high threshold, partial description overlap may fail
|
||||
expect(issuesSimilar(a, b, 0.99)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isNoTestProject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isNoTestProject', () => {
|
||||
const PROJECT_DIR = '/my-project';
|
||||
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReset().mockReturnValue(false);
|
||||
mockReaddirSync.mockReset().mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('returns false when vitest.config.ts exists', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith('vitest.config.ts'));
|
||||
expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when jest.config.js exists', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith('jest.config.js'));
|
||||
expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when pytest.ini exists', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith('pytest.ini'));
|
||||
expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when test files are found in __tests__ directory', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith('__tests__'));
|
||||
mockReaddirSync.mockReturnValue(['auth.test.ts', 'utils.test.ts']);
|
||||
expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when no test config files and no test directories exist', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when test directories exist but contain no test files', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith('tests'));
|
||||
mockReaddirSync.mockReturnValue(['README.md', 'fixtures.json']);
|
||||
expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles readdir errors gracefully and returns true', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith('tests'));
|
||||
mockReaddirSync.mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — declared before any imports that pull in the mocked modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
const mockWriteFile = vi.fn();
|
||||
const mockMkdir = vi.fn();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: (...args: unknown[]) => mockReadFile(...args),
|
||||
writeFile: (...args: unknown[]) => mockWriteFile(...args),
|
||||
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/json-repair', () => ({
|
||||
safeParseJson: (raw: string) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { RecoveryManager } from '../recovery-manager';
|
||||
import type { BuildCheckpoint, FailureType } from '../recovery-manager';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECT_DIR = path.join(path.sep, 'project');
|
||||
const SPEC_DIR = path.join(PROJECT_DIR, '.aperant', 'specs', '001-feature');
|
||||
const MEMORY_DIR = path.join(SPEC_DIR, 'memory');
|
||||
const ATTEMPT_HISTORY_PATH = path.join(MEMORY_DIR, 'attempt_history.json');
|
||||
|
||||
function makeHistory(
|
||||
subtasks: Record<string, Array<{ timestamp: string; error: string; failureType: FailureType; errorHash: string }>>,
|
||||
stuckSubtasks: string[] = [],
|
||||
) {
|
||||
return JSON.stringify({
|
||||
subtasks,
|
||||
stuckSubtasks,
|
||||
metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
function recentTimestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function oldTimestamp() {
|
||||
// 3 hours ago — outside the 2-hour window
|
||||
return new Date(Date.now() - 3 * 60 * 60 * 1_000).toISOString();
|
||||
}
|
||||
|
||||
function createManager() {
|
||||
return new RecoveryManager(SPEC_DIR, PROJECT_DIR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyFailure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecoveryManager.classifyFailure', () => {
|
||||
let manager: RecoveryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
const cases: Array<[string, FailureType]> = [
|
||||
['SyntaxError: Unexpected token', 'broken_build'],
|
||||
['Module not found: react', 'broken_build'],
|
||||
['compilation error in main.ts', 'broken_build'],
|
||||
['cannot find module lodash', 'broken_build'],
|
||||
// 'IndentationError' is not in the source's buildErrors list — removed
|
||||
['parse error in config.js', 'broken_build'],
|
||||
|
||||
['verification failed: response mismatch', 'verification_failed'],
|
||||
['AssertionError: expected 1 to equal 2', 'verification_failed'],
|
||||
['test failed: missing element', 'verification_failed'],
|
||||
['status code 404 received', 'verification_failed'],
|
||||
|
||||
['context window exceeded', 'context_exhausted'],
|
||||
['token limit reached', 'context_exhausted'],
|
||||
['maximum length of response reached', 'context_exhausted'],
|
||||
|
||||
['429 too many requests', 'rate_limited'],
|
||||
['rate limit exceeded', 'rate_limited'],
|
||||
['too many requests from your IP', 'rate_limited'],
|
||||
|
||||
['401 unauthorized access', 'auth_failure'],
|
||||
['auth token expired', 'auth_failure'],
|
||||
|
||||
['a totally random and obscure crash', 'unknown'],
|
||||
['', 'unknown'],
|
||||
];
|
||||
|
||||
it.each(cases)('classifies "%s" as %s', (error, expected) => {
|
||||
expect(manager.classifyFailure(error, 'subtask-1')).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkpoint save / load round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecoveryManager checkpoint round-trip', () => {
|
||||
let manager: RecoveryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWriteFile.mockReset().mockResolvedValue(undefined);
|
||||
mockReadFile.mockReset();
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
it('writes a parseable checkpoint and loads it back', async () => {
|
||||
const checkpoint: BuildCheckpoint = {
|
||||
specId: '001',
|
||||
phase: 'coding',
|
||||
lastCompletedSubtaskId: 'subtask-3',
|
||||
totalSubtasks: 5,
|
||||
completedSubtasks: 3,
|
||||
stuckSubtasks: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
// Save captures what was written
|
||||
let writtenContent = '';
|
||||
mockWriteFile.mockImplementation((_path: string, content: string) => {
|
||||
writtenContent = content;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await manager.saveCheckpoint(checkpoint);
|
||||
|
||||
// Verify writeFile was called with the progress file path
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
path.join(SPEC_DIR, 'build-progress.txt'),
|
||||
expect.stringContaining('spec_id: 001'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Now load the checkpoint from what was written
|
||||
mockReadFile.mockResolvedValueOnce(writtenContent);
|
||||
const loaded = await manager.loadCheckpoint();
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.specId).toBe('001');
|
||||
expect(loaded?.phase).toBe('coding');
|
||||
expect(loaded?.lastCompletedSubtaskId).toBe('subtask-3');
|
||||
expect(loaded?.totalSubtasks).toBe(5);
|
||||
expect(loaded?.completedSubtasks).toBe(3);
|
||||
expect(loaded?.isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('saves lastCompletedSubtaskId=null as "none" and reloads as null', async () => {
|
||||
const checkpoint: BuildCheckpoint = {
|
||||
specId: '002',
|
||||
phase: 'planning',
|
||||
lastCompletedSubtaskId: null,
|
||||
totalSubtasks: 3,
|
||||
completedSubtasks: 0,
|
||||
stuckSubtasks: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
let writtenContent = '';
|
||||
mockWriteFile.mockImplementation((_path: string, content: string) => {
|
||||
writtenContent = content;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await manager.saveCheckpoint(checkpoint);
|
||||
expect(writtenContent).toContain('last_completed_subtask: none');
|
||||
|
||||
mockReadFile.mockResolvedValueOnce(writtenContent);
|
||||
const loaded = await manager.loadCheckpoint();
|
||||
expect(loaded?.lastCompletedSubtaskId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no checkpoint file exists', async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
const loaded = await manager.loadCheckpoint();
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
|
||||
it('saves stuckSubtasks correctly', async () => {
|
||||
const checkpoint: BuildCheckpoint = {
|
||||
specId: '003',
|
||||
phase: 'coding',
|
||||
lastCompletedSubtaskId: null,
|
||||
totalSubtasks: 4,
|
||||
completedSubtasks: 1,
|
||||
stuckSubtasks: ['subtask-1', 'subtask-2'],
|
||||
timestamp: new Date().toISOString(),
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
let writtenContent = '';
|
||||
mockWriteFile.mockImplementation((_path: string, content: string) => {
|
||||
writtenContent = content;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await manager.saveCheckpoint(checkpoint);
|
||||
|
||||
mockReadFile.mockResolvedValueOnce(writtenContent);
|
||||
const loaded = await manager.loadCheckpoint();
|
||||
expect(loaded?.stuckSubtasks).toEqual(['subtask-1', 'subtask-2']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circular fix detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecoveryManager.isCircularFix', () => {
|
||||
let manager: RecoveryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
mockWriteFile.mockReset().mockResolvedValue(undefined);
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
it('returns false when fewer than 3 identical errors exist', async () => {
|
||||
// Produce a real hash by calling classifyFailure indirectly
|
||||
// We need the same hash that simpleHash("same error") would produce.
|
||||
// We'll record 2 attempts with the same error, then check.
|
||||
const sameError = 'same error message';
|
||||
|
||||
// Build a history with 2 records that share the same errorHash
|
||||
// We compute the hash the same way the source does: via recordAttempt
|
||||
// Here we mock the file system to return a pre-built history.
|
||||
// For simplicity, we simulate 2 identical hashes manually.
|
||||
const history = {
|
||||
subtasks: {
|
||||
'task-1': [
|
||||
{ timestamp: recentTimestamp(), error: sameError, failureType: 'unknown', errorHash: 'aaa' },
|
||||
{ timestamp: recentTimestamp(), error: sameError, failureType: 'unknown', errorHash: 'aaa' },
|
||||
],
|
||||
},
|
||||
stuckSubtasks: [],
|
||||
metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() },
|
||||
};
|
||||
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(history));
|
||||
const result = await manager.isCircularFix('task-1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when 3 or more identical error hashes exist within the window', async () => {
|
||||
const history = {
|
||||
subtasks: {
|
||||
'task-1': [
|
||||
{ timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'bbb' },
|
||||
{ timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'bbb' },
|
||||
{ timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'bbb' },
|
||||
],
|
||||
},
|
||||
stuckSubtasks: [],
|
||||
metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() },
|
||||
};
|
||||
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(history));
|
||||
const result = await manager.isCircularFix('task-1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores attempts outside the 2-hour window', async () => {
|
||||
const history = {
|
||||
subtasks: {
|
||||
'task-1': [
|
||||
// Two old entries — outside window
|
||||
{ timestamp: oldTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' },
|
||||
{ timestamp: oldTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' },
|
||||
{ timestamp: oldTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' },
|
||||
// One recent entry
|
||||
{ timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' },
|
||||
],
|
||||
},
|
||||
stuckSubtasks: [],
|
||||
metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() },
|
||||
};
|
||||
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(history));
|
||||
const result = await manager.isCircularFix('task-1');
|
||||
// Only 1 recent entry → not circular
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a subtask with no attempt history', async () => {
|
||||
mockReadFile.mockResolvedValue(makeHistory({}));
|
||||
const result = await manager.isCircularFix('no-such-task');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attempt window filtering via getAttemptCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecoveryManager.getAttemptCount', () => {
|
||||
let manager: RecoveryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
mockWriteFile.mockReset().mockResolvedValue(undefined);
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
it('counts only recent attempts within the 2-hour window', async () => {
|
||||
const history = {
|
||||
subtasks: {
|
||||
'task-x': [
|
||||
{ timestamp: oldTimestamp(), error: 'old error', failureType: 'unknown', errorHash: 'h1' },
|
||||
{ timestamp: recentTimestamp(), error: 'new error 1', failureType: 'unknown', errorHash: 'h2' },
|
||||
{ timestamp: recentTimestamp(), error: 'new error 2', failureType: 'unknown', errorHash: 'h3' },
|
||||
],
|
||||
},
|
||||
stuckSubtasks: [],
|
||||
metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() },
|
||||
};
|
||||
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(history));
|
||||
const count = await manager.getAttemptCount('task-x');
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for unknown subtask', async () => {
|
||||
mockReadFile.mockResolvedValue(makeHistory({}));
|
||||
const count = await manager.getAttemptCount('ghost-task');
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// determineRecoveryAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecoveryManager.determineRecoveryAction', () => {
|
||||
let manager: RecoveryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
mockWriteFile.mockReset().mockResolvedValue(undefined);
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
it('escalates immediately when circular fix detected', async () => {
|
||||
// 3 identical error hashes → circular
|
||||
const history = {
|
||||
subtasks: {
|
||||
'task-circ': [
|
||||
{ timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'xyz' },
|
||||
{ timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'xyz' },
|
||||
{ timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'xyz' },
|
||||
],
|
||||
},
|
||||
stuckSubtasks: [],
|
||||
metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() },
|
||||
};
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(history));
|
||||
|
||||
const action = await manager.determineRecoveryAction('task-circ', 'err', 5);
|
||||
expect(action.action).toBe('escalate');
|
||||
expect(action.reason).toMatch(/circular/i);
|
||||
});
|
||||
|
||||
it('skips when attempt count >= maxRetries', async () => {
|
||||
const history = {
|
||||
subtasks: {
|
||||
'task-skip': [
|
||||
{ timestamp: recentTimestamp(), error: 'fail', failureType: 'unknown', errorHash: 'a1' },
|
||||
{ timestamp: recentTimestamp(), error: 'fail', failureType: 'unknown', errorHash: 'a2' },
|
||||
{ timestamp: recentTimestamp(), error: 'fail', failureType: 'unknown', errorHash: 'a3' },
|
||||
],
|
||||
},
|
||||
stuckSubtasks: [],
|
||||
metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() },
|
||||
};
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(history));
|
||||
|
||||
const action = await manager.determineRecoveryAction('task-skip', 'fail', 3);
|
||||
expect(action.action).toBe('skip');
|
||||
expect(action.reason).toMatch(/max retries/i);
|
||||
});
|
||||
|
||||
it('escalates on auth failure', async () => {
|
||||
mockReadFile.mockResolvedValue(makeHistory({ 'task-auth': [] }));
|
||||
const action = await manager.determineRecoveryAction('task-auth', '401 unauthorized', 5);
|
||||
expect(action.action).toBe('escalate');
|
||||
expect(action.reason).toMatch(/auth/i);
|
||||
});
|
||||
|
||||
it('retries on rate limit', async () => {
|
||||
mockReadFile.mockResolvedValue(makeHistory({ 'task-rl': [] }));
|
||||
const action = await manager.determineRecoveryAction('task-rl', '429 rate limit exceeded', 5);
|
||||
expect(action.action).toBe('retry');
|
||||
expect(action.reason).toMatch(/rate limit/i);
|
||||
});
|
||||
|
||||
it('retries on context exhaustion', async () => {
|
||||
mockReadFile.mockResolvedValue(makeHistory({ 'task-ctx': [] }));
|
||||
const action = await manager.determineRecoveryAction('task-ctx', 'context window exceeded', 5);
|
||||
expect(action.action).toBe('retry');
|
||||
expect(action.reason).toMatch(/context/i);
|
||||
});
|
||||
|
||||
it('defaults to retry for unknown failure types', async () => {
|
||||
mockReadFile.mockResolvedValue(makeHistory({ 'task-unk': [] }));
|
||||
const action = await manager.determineRecoveryAction('task-unk', 'something weird', 5);
|
||||
expect(action.action).toBe('retry');
|
||||
expect(action.target).toBe('task-unk');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// init — directory creation and history bootstrap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecoveryManager.init', () => {
|
||||
let manager: RecoveryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMkdir.mockReset().mockResolvedValue(undefined);
|
||||
mockReadFile.mockReset();
|
||||
mockWriteFile.mockReset().mockResolvedValue(undefined);
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
it('creates memory directory with recursive flag', async () => {
|
||||
// Simulate history file already existing
|
||||
mockReadFile.mockResolvedValueOnce(makeHistory({}));
|
||||
await manager.init();
|
||||
expect(mockMkdir).toHaveBeenCalledWith(MEMORY_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('writes an empty history when no history file exists', async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
await manager.init();
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
ATTEMPT_HISTORY_PATH,
|
||||
expect.stringContaining('"subtasks"'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// markStuck / isStuck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecoveryManager stuck tracking', () => {
|
||||
let manager: RecoveryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
mockWriteFile.mockReset().mockResolvedValue(undefined);
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
it('marks a subtask as stuck and detects it', async () => {
|
||||
let storedHistory = makeHistory({}, []);
|
||||
|
||||
mockReadFile.mockImplementation(() => Promise.resolve(storedHistory));
|
||||
mockWriteFile.mockImplementation((_path: string, content: string) => {
|
||||
storedHistory = content;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await manager.markStuck('task-stuck');
|
||||
|
||||
expect(await manager.isStuck('task-stuck')).toBe(true);
|
||||
expect(await manager.isStuck('task-fine')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not duplicate a subtask when marked stuck twice', async () => {
|
||||
let storedHistory = makeHistory({}, []);
|
||||
|
||||
mockReadFile.mockImplementation(() => Promise.resolve(storedHistory));
|
||||
mockWriteFile.mockImplementation((_path: string, content: string) => {
|
||||
storedHistory = content;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await manager.markStuck('task-dup');
|
||||
await manager.markStuck('task-dup');
|
||||
|
||||
const parsed = JSON.parse(storedHistory) as { stuckSubtasks: string[] };
|
||||
expect(parsed.stuckSubtasks.filter((id) => id === 'task-dup')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ const PHASE_CONFIG_MAP: Record<BuildPhase, Phase> = {
|
||||
|
||||
/** Configuration for the build orchestrator */
|
||||
export interface BuildOrchestratorConfig {
|
||||
/** Spec directory path (e.g., .auto-claude/specs/001-feature/) */
|
||||
/** Spec directory path (e.g., .aperant/specs/001-feature/) */
|
||||
specDir: string;
|
||||
/** Project root directory */
|
||||
projectDir: string;
|
||||
|
||||
@@ -40,8 +40,8 @@ import type {
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROFILE_FILENAME = '.auto-claude-security.json';
|
||||
const CUSTOM_ALLOWLIST_FILENAME = '.auto-claude-allowlist';
|
||||
const PROFILE_FILENAME = '.aperant-security.json';
|
||||
const CUSTOM_ALLOWLIST_FILENAME = '.aperant-allowlist';
|
||||
|
||||
const HASH_FILES = [
|
||||
'package.json',
|
||||
|
||||
@@ -35,7 +35,7 @@ const SKIP_DIRS = new Set([
|
||||
'.nuxt',
|
||||
'target',
|
||||
'vendor',
|
||||
'.auto-claude',
|
||||
'.aperant',
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
]);
|
||||
|
||||
@@ -402,10 +402,10 @@ function validateBranchName(branch: string | null | undefined): string | null {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Load project_index.json from the project's .auto-claude directory.
|
||||
* Load project_index.json from the project's .aperant directory.
|
||||
*/
|
||||
export function loadProjectIndex(projectDir: string): Record<string, unknown> {
|
||||
const indexPath = join(projectDir, '.auto-claude', 'project_index.json');
|
||||
const indexPath = join(projectDir, '.aperant', 'project_index.json');
|
||||
if (!existsSync(indexPath)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(indexPath, 'utf-8')) as Record<string, unknown>;
|
||||
|
||||
@@ -28,8 +28,8 @@ import type {
|
||||
|
||||
/** Patterns to detect worktree isolation */
|
||||
const WORKTREE_PATH_PATTERNS = [
|
||||
/[/\\]\.auto-claude[/\\]worktrees[/\\]tasks[/\\]/,
|
||||
/[/\\]\.auto-claude[/\\]github[/\\]pr[/\\]worktrees[/\\]/,
|
||||
/[/\\]\.aperant[/\\]worktrees[/\\]tasks[/\\]/,
|
||||
/[/\\]\.aperant[/\\]github[/\\]pr[/\\]worktrees[/\\]/,
|
||||
/[/\\]\.worktrees[/\\]/,
|
||||
];
|
||||
|
||||
@@ -106,7 +106,7 @@ function getRelativeSpecPath(specDir: string, projectDir: string): string {
|
||||
|
||||
// Fallback: just use the spec dir name
|
||||
const parts = resolvedSpec.split(/[/\\]/);
|
||||
return `./auto-claude/specs/${parts[parts.length - 1]}`;
|
||||
return `./aperant/specs/${parts[parts.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,7 +331,7 @@ export async function generateSubtaskPrompt(config: SubtaskPromptConfig): Promis
|
||||
`5. **Commit your changes:**\n` +
|
||||
` \`\`\`bash\n` +
|
||||
` git add .\n` +
|
||||
` git commit -m "auto-claude: ${subtask.id} - ${subtask.description.slice(0, 50)}"\n` +
|
||||
` git commit -m "aperant: ${subtask.id} - ${subtask.description.slice(0, 50)}"\n` +
|
||||
` \`\`\`\n` +
|
||||
`6. **Update the plan** - set this subtask's status to "completed" in implementation_plan.json\n\n` +
|
||||
`## Quality Checklist\n\n` +
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockGenerateText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import { generateChangelog } from '../changelog';
|
||||
import type { ChangelogConfig } from '../changelog';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/** A fake model object used by the mock client */
|
||||
const fakeModel = { modelId: 'claude-haiku-test' };
|
||||
|
||||
function makeMockClient(systemPrompt = 'You are a technical writer.') {
|
||||
return { model: fakeModel, systemPrompt };
|
||||
}
|
||||
|
||||
function baseConfig(overrides: Partial<ChangelogConfig> = {}): ChangelogConfig {
|
||||
return {
|
||||
projectName: 'TestProject',
|
||||
version: '1.0.0',
|
||||
sourceMode: 'tasks',
|
||||
tasks: [
|
||||
{ title: 'Add dark mode', description: 'Implemented dark mode toggle', category: 'feature' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('generateChangelog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Successful generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns success with trimmed text when LLM responds', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: ' ## [1.0.0]\n\n### Added\n- Dark mode\n ' });
|
||||
|
||||
const result = await generateChangelog(baseConfig());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toBe('## [1.0.0]\n\n### Added\n- Dark mode');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes project name and version in the prompt to createSimpleClient', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [2.0.0]' });
|
||||
|
||||
await generateChangelog(baseConfig({ projectName: 'MyApp', version: '2.0.0' }));
|
||||
|
||||
// createSimpleClient receives system-level configuration
|
||||
expect(mockCreateSimpleClient).toHaveBeenCalledOnce();
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs).toHaveProperty('modelShorthand');
|
||||
expect(clientArgs).toHaveProperty('thinkingLevel');
|
||||
});
|
||||
|
||||
it('passes model and systemPrompt from client to generateText', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' });
|
||||
|
||||
await generateChangelog(baseConfig());
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledOnce();
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe(fakeModel);
|
||||
expect(callArgs.system).toBe('You are a technical writer.');
|
||||
expect(callArgs.prompt).toContain('TestProject');
|
||||
expect(callArgs.prompt).toContain('1.0.0');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task mode — prompt content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('includes task titles and categories in prompt for tasks mode', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' });
|
||||
|
||||
const config = baseConfig({
|
||||
tasks: [
|
||||
{ title: 'My feature', description: 'desc', category: 'feature', issueNumber: 42 },
|
||||
],
|
||||
});
|
||||
await generateChangelog(config);
|
||||
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).toContain('My feature');
|
||||
expect(prompt).toContain('feature');
|
||||
expect(prompt).toContain('#42');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git history / branch-diff modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('includes commit messages in prompt for git-history mode', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' });
|
||||
|
||||
await generateChangelog(
|
||||
baseConfig({ sourceMode: 'git-history', commits: 'feat: add login\nfix: bug #5' }),
|
||||
);
|
||||
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).toContain('feat: add login');
|
||||
});
|
||||
|
||||
it('truncates commits to 5000 chars', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' });
|
||||
const longCommits = 'x'.repeat(10_000);
|
||||
|
||||
await generateChangelog(baseConfig({ sourceMode: 'branch-diff', commits: longCommits }));
|
||||
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
// The 'x'.repeat(10000) block should be truncated — prompt must not exceed
|
||||
// 5000 'x' chars plus surrounding text
|
||||
const xCount = (prompt.match(/x/g) ?? []).length;
|
||||
expect(xCount).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Previous changelog style reference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('includes previousChangelog when provided', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' });
|
||||
|
||||
await generateChangelog(
|
||||
baseConfig({ previousChangelog: '## [0.9.0]\n\n### Added\n- Old feature' }),
|
||||
);
|
||||
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).toContain('Previous Changelog');
|
||||
expect(prompt).toContain('0.9.0');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default model / thinking level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses sonnet model and low thinking level by default', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' });
|
||||
|
||||
await generateChangelog(baseConfig());
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('accepts custom modelShorthand and thinkingLevel', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' });
|
||||
|
||||
await generateChangelog(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'high' }));
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('high');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty response handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure when LLM returns empty text', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: ' ' });
|
||||
|
||||
const result = await generateChangelog(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.error).toBe('Empty response from AI');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure with error message when generateText throws', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('Rate limit exceeded'));
|
||||
|
||||
const result = await generateChangelog(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.error).toBe('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('returns failure with string coercion when non-Error is thrown', async () => {
|
||||
mockGenerateText.mockRejectedValue('timeout');
|
||||
|
||||
const result = await generateChangelog(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('timeout');
|
||||
});
|
||||
|
||||
it('returns failure when createSimpleClient throws', async () => {
|
||||
mockCreateSimpleClient.mockRejectedValue(new Error('No auth available'));
|
||||
|
||||
const result = await generateChangelog(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No auth available');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockGenerateText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// Mock filesystem access so tests are hermetic
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockReadFileSync = vi.fn();
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
}));
|
||||
|
||||
// json-repair is used by the commit-message runner for safeParseJson
|
||||
vi.mock('../../../utils/json-repair', () => ({
|
||||
safeParseJson: (text: string) => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import { generateCommitMessage } from '../commit-message';
|
||||
import type { CommitMessageConfig } from '../commit-message';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const fakeModel = { modelId: 'claude-haiku-test' };
|
||||
|
||||
function makeMockClient(systemPrompt = 'You are a Git expert.') {
|
||||
return { model: fakeModel, systemPrompt };
|
||||
}
|
||||
|
||||
function baseConfig(overrides: Partial<CommitMessageConfig> = {}): CommitMessageConfig {
|
||||
return {
|
||||
projectDir: '/project',
|
||||
specName: '001-add-feature',
|
||||
diffSummary: '+5 -2 src/app.ts',
|
||||
filesChanged: ['src/app.ts', 'src/utils.ts'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('generateCommitMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
// By default, spec directory does not exist
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Successful generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns trimmed AI-generated commit message on success', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: ' feat(app): add authentication flow\n\nImplemented OAuth2.\n ',
|
||||
});
|
||||
|
||||
const result = await generateCommitMessage(baseConfig());
|
||||
|
||||
expect(result).toBe('feat(app): add authentication flow\n\nImplemented OAuth2.');
|
||||
});
|
||||
|
||||
it('passes model and systemPrompt from client to generateText', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'feat: something' });
|
||||
|
||||
await generateCommitMessage(baseConfig());
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledOnce();
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe(fakeModel);
|
||||
expect(callArgs.system).toBe('You are a Git expert.');
|
||||
});
|
||||
|
||||
it('includes diffSummary in the prompt sent to generateText', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'fix: resolve bug' });
|
||||
|
||||
await generateCommitMessage(baseConfig({ diffSummary: 'removed null check in auth.ts' }));
|
||||
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).toContain('removed null check in auth.ts');
|
||||
});
|
||||
|
||||
it('includes filesChanged in the prompt', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'refactor: split utilities' });
|
||||
|
||||
await generateCommitMessage(
|
||||
baseConfig({ filesChanged: ['src/auth.ts', 'src/utils.ts', 'src/index.ts'] }),
|
||||
);
|
||||
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).toContain('src/auth.ts');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default model / thinking level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses haiku model and low thinking level by default', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'chore: update deps' });
|
||||
|
||||
await generateCommitMessage(baseConfig());
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('accepts custom modelShorthand and thinkingLevel', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'feat: new endpoint' });
|
||||
|
||||
await generateCommitMessage(baseConfig({ modelShorthand: 'sonnet', thinkingLevel: 'medium' }));
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub issue handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('includes Fixes reference when githubIssue is provided', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'fix: null pointer\n\nFixes #99' });
|
||||
|
||||
const result = await generateCommitMessage(baseConfig({ githubIssue: 99 }));
|
||||
|
||||
expect(result).toContain('Fixes #99');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spec file context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('reads spec.md for title when spec directory exists', async () => {
|
||||
// Spec directory at .aperant/specs/001-add-feature
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
const normalized = p.replace(/\\/g, '/');
|
||||
if (normalized.includes('specs/001-add-feature')) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p.includes('spec.md')) return '# Add OAuth Feature\n\n## Overview\nFull OAuth2 support.';
|
||||
return '{}';
|
||||
});
|
||||
mockGenerateText.mockResolvedValue({ text: 'feat(auth): add OAuth2' });
|
||||
|
||||
const result = await generateCommitMessage(baseConfig());
|
||||
|
||||
// Result should come from LLM (title from spec was available for context)
|
||||
expect(result).toBe('feat(auth): add OAuth2');
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).toContain('Add OAuth Feature');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns fallback message when generateText throws', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await generateCommitMessage(baseConfig({ specName: '001-add-feature' }));
|
||||
|
||||
// Fallback format: "<type>: <title or specName>"
|
||||
expect(result).toMatch(/^(feat|fix|refactor|docs|test|perf|chore|style|ci|build):/);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes Fixes in fallback when githubIssue provided and LLM fails', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('Timeout'));
|
||||
|
||||
const result = await generateCommitMessage(baseConfig({ githubIssue: 77 }));
|
||||
|
||||
expect(result).toContain('Fixes #77');
|
||||
});
|
||||
|
||||
it('returns fallback when LLM returns empty text', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: ' ' });
|
||||
|
||||
const result = await generateCommitMessage(baseConfig());
|
||||
|
||||
// Should fall through to fallback
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Large filesChanged list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('truncates filesChanged list when more than 20 files', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'refactor: big cleanup' });
|
||||
|
||||
const manyFiles = Array.from({ length: 30 }, (_, i) => `src/file${i}.ts`);
|
||||
await generateCommitMessage(baseConfig({ filesChanged: manyFiles }));
|
||||
|
||||
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).toContain('and 10 more files');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockStreamText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
stepCountIs: (n: number) => ({ type: 'stepCount', count: n }),
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// Mock filesystem: prompt files exist by default
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockReadFileSync = vi.fn();
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
}));
|
||||
|
||||
// Mock the tool registry so we don't need real tool initialization
|
||||
vi.mock('../../tools/build-registry', () => ({
|
||||
buildToolRegistry: () => ({
|
||||
getToolsForAgent: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import { runIdeation, IDEATION_TYPES, IDEATION_TYPE_LABELS } from '../ideation';
|
||||
import type { IdeationConfig, IdeationStreamEvent } from '../ideation';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const fakeModel = { modelId: 'claude-sonnet-test' };
|
||||
|
||||
function makeMockClient() {
|
||||
return {
|
||||
model: fakeModel,
|
||||
systemPrompt: '',
|
||||
tools: {},
|
||||
maxSteps: 30,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an async generator that yields stream parts and then ends.
|
||||
*/
|
||||
function makeStream(parts: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
for (const part of parts) {
|
||||
yield part;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function baseConfig(overrides: Partial<IdeationConfig> = {}): IdeationConfig {
|
||||
return {
|
||||
projectDir: '/project',
|
||||
outputDir: '/project/.aperant/ideation',
|
||||
promptsDir: '/app/prompts',
|
||||
ideationType: 'code_improvements',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('runIdeation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
// Prompt file exists and has content by default
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('Analyze the codebase for improvements.');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('exports all expected IDEATION_TYPES', () => {
|
||||
expect(IDEATION_TYPES).toContain('code_improvements');
|
||||
expect(IDEATION_TYPES).toContain('ui_ux_improvements');
|
||||
expect(IDEATION_TYPES).toContain('documentation_gaps');
|
||||
expect(IDEATION_TYPES).toContain('security_hardening');
|
||||
expect(IDEATION_TYPES).toContain('performance_optimizations');
|
||||
expect(IDEATION_TYPES).toContain('code_quality');
|
||||
expect(IDEATION_TYPES).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('exports human-readable labels for all ideation types', () => {
|
||||
for (const type of IDEATION_TYPES) {
|
||||
expect(IDEATION_TYPE_LABELS[type]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Successful run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns success with accumulated text from stream', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: 'text-delta', text: 'Found ' },
|
||||
{ type: 'text-delta', text: '3 improvements.' },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runIdeation(baseConfig());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toBe('Found 3 improvements.');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls createSimpleClient with sonnet and medium thinking by default', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runIdeation(baseConfig());
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
it('accepts custom modelShorthand and thinkingLevel', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runIdeation(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'low' }));
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('passes tools from client to streamText', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runIdeation(baseConfig());
|
||||
|
||||
const streamArgs = mockStreamText.mock.calls[0][0];
|
||||
expect(streamArgs).toHaveProperty('tools');
|
||||
expect(streamArgs).toHaveProperty('model');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('forwards text-delta events to onStream callback', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: 'text-delta', text: 'hello' },
|
||||
{ type: 'text-delta', text: ' world' },
|
||||
]),
|
||||
);
|
||||
|
||||
const events: IdeationStreamEvent[] = [];
|
||||
await runIdeation(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const textEvents = events.filter((e) => e.type === 'text-delta');
|
||||
expect(textEvents).toHaveLength(2);
|
||||
expect((textEvents[0] as { type: 'text-delta'; text: string }).text).toBe('hello');
|
||||
});
|
||||
|
||||
it('forwards tool-use events from tool-call stream parts', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([{ type: 'tool-call', toolName: 'Glob', toolCallId: 'c1', input: {} }]),
|
||||
);
|
||||
|
||||
const events: IdeationStreamEvent[] = [];
|
||||
await runIdeation(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const toolEvents = events.filter((e) => e.type === 'tool-use');
|
||||
expect(toolEvents).toHaveLength(1);
|
||||
expect((toolEvents[0] as { type: 'tool-use'; name: string }).name).toBe('Glob');
|
||||
});
|
||||
|
||||
it('forwards error events from stream error parts', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([{ type: 'error', error: new Error('stream error') }]),
|
||||
);
|
||||
|
||||
const events: IdeationStreamEvent[] = [];
|
||||
await runIdeation(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const errorEvents = events.filter((e) => e.type === 'error');
|
||||
expect(errorEvents).toHaveLength(1);
|
||||
expect((errorEvents[0] as { type: 'error'; error: string }).error).toBe('stream error');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt file not found
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure when prompt file does not exist', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = await runIdeation(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.error).toContain('Prompt not found');
|
||||
});
|
||||
|
||||
it('returns failure when prompt file cannot be read', async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
const result = await runIdeation(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Permission denied');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling — streamText throws
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure when streamText iteration throws', async () => {
|
||||
mockStreamText.mockReturnValue({
|
||||
// biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path
|
||||
fullStream: (async function* () {
|
||||
throw new Error('API error');
|
||||
})(),
|
||||
});
|
||||
|
||||
const result = await runIdeation(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('API error');
|
||||
});
|
||||
|
||||
it('emits error event to callback when streamText throws', async () => {
|
||||
mockStreamText.mockReturnValue({
|
||||
// biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path
|
||||
fullStream: (async function* () {
|
||||
throw new Error('network failure');
|
||||
})(),
|
||||
});
|
||||
|
||||
const events: IdeationStreamEvent[] = [];
|
||||
await runIdeation(baseConfig(), (e) => events.push(e));
|
||||
|
||||
expect(events.some((e) => e.type === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ideation type routing — checks the correct prompt file is loaded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it.each(IDEATION_TYPES)('loads the correct prompt file for ideation type: %s', async (type) => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runIdeation(baseConfig({ ideationType: type }));
|
||||
|
||||
// The prompt file for each type should have been checked for existence
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(expect.stringContaining('.md'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('includes projectDir and outputDir in the prompt passed to streamText', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runIdeation(
|
||||
baseConfig({ projectDir: '/my/project', outputDir: '/my/project/.aperant/ideation' }),
|
||||
);
|
||||
|
||||
// The system prompt passed to streamText should contain the project dir
|
||||
const streamArgs = mockStreamText.mock.calls[0][0];
|
||||
const systemPrompt = streamArgs.system as string;
|
||||
expect(systemPrompt).toContain('/my/project');
|
||||
});
|
||||
|
||||
it('injects maxIdeasPerType into the context', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runIdeation(baseConfig({ maxIdeasPerType: 10 }));
|
||||
|
||||
const streamArgs = mockStreamText.mock.calls[0][0];
|
||||
const systemPrompt = streamArgs.system as string;
|
||||
expect(systemPrompt).toContain('10');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockGenerateText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
Output: {
|
||||
object: ({ schema }: { schema: unknown }) => ({ type: 'object', schema }),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// Mock schema/structured-output so we don't need the actual implementation
|
||||
vi.mock('../../schema/structured-output', () => ({
|
||||
parseLLMJson: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
// Mock the Zod schemas used by the runner
|
||||
vi.mock('../../schema/insight-extractor', () => ({
|
||||
ExtractedInsightsSchema: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../schema/output', () => ({
|
||||
ExtractedInsightsOutputSchema: {},
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import { extractSessionInsights } from '../insight-extractor';
|
||||
import type { InsightExtractionConfig } from '../insight-extractor';
|
||||
import { parseLLMJson } from '../../schema/structured-output';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const fakeModel = { modelId: 'claude-haiku-test' };
|
||||
|
||||
function makeMockClient() {
|
||||
return { model: fakeModel, systemPrompt: 'You are an expert code analyst.' };
|
||||
}
|
||||
|
||||
function makeValidOutput() {
|
||||
return {
|
||||
file_insights: [{ file: 'src/app.ts', insight: 'Uses singleton pattern', category: 'pattern' }],
|
||||
patterns_discovered: ['Singleton pattern used'],
|
||||
gotchas_discovered: ['Must call init() before use'],
|
||||
approach_outcome: {
|
||||
success: true,
|
||||
approach_used: 'Direct refactor',
|
||||
why_it_worked: 'Simplified the module',
|
||||
why_it_failed: null,
|
||||
alternatives_tried: [],
|
||||
},
|
||||
recommendations: ['Add unit tests for singleton'],
|
||||
};
|
||||
}
|
||||
|
||||
function baseConfig(overrides: Partial<InsightExtractionConfig> = {}): InsightExtractionConfig {
|
||||
return {
|
||||
subtaskId: 'sub-001',
|
||||
subtaskDescription: 'Refactor authentication module',
|
||||
sessionNum: 1,
|
||||
success: true,
|
||||
diff: 'diff --git a/src/auth.ts b/src/auth.ts\n+ return token;',
|
||||
changedFiles: ['src/auth.ts'],
|
||||
commitMessages: 'refactor: simplify auth module',
|
||||
attemptHistory: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('extractSessionInsights', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
// By default, result.output contains the structured data (constrained decoding path)
|
||||
mockGenerateText.mockResolvedValue({
|
||||
output: makeValidOutput(),
|
||||
text: '',
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Successful extraction via result.output (constrained decoding)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns extracted insights from result.output when available', async () => {
|
||||
const result = await extractSessionInsights(baseConfig());
|
||||
|
||||
expect(result.subtask_id).toBe('sub-001');
|
||||
expect(result.session_num).toBe(1);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.changed_files).toEqual(['src/auth.ts']);
|
||||
expect(result.file_insights).toHaveLength(1);
|
||||
expect(result.file_insights[0].file).toBe('src/app.ts');
|
||||
expect(result.patterns_discovered).toContain('Singleton pattern used');
|
||||
expect(result.gotchas_discovered).toContain('Must call init() before use');
|
||||
expect(result.recommendations).toContain('Add unit tests for singleton');
|
||||
});
|
||||
|
||||
it('populates approach_outcome from result.output', async () => {
|
||||
const result = await extractSessionInsights(baseConfig());
|
||||
|
||||
expect(result.approach_outcome.success).toBe(true);
|
||||
expect(result.approach_outcome.approach_used).toBe('Direct refactor');
|
||||
expect(result.approach_outcome.why_it_worked).toBe('Simplified the module');
|
||||
expect(result.approach_outcome.why_it_failed).toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback to parseLLMJson when result.output is absent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('falls back to parseLLMJson when result.output is null/undefined', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
output: null,
|
||||
text: JSON.stringify({
|
||||
file_insights: [{ file: 'src/login.ts', insight: 'Heavy coupling' }],
|
||||
patterns_discovered: ['MVC'],
|
||||
gotchas_discovered: [],
|
||||
approach_outcome: {
|
||||
success: false,
|
||||
approach_used: 'monkey-patch',
|
||||
why_it_worked: null,
|
||||
why_it_failed: 'Too hacky',
|
||||
alternatives_tried: [],
|
||||
},
|
||||
recommendations: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const parsedData = {
|
||||
file_insights: [{ file: 'src/login.ts', insight: 'Heavy coupling' }],
|
||||
patterns_discovered: ['MVC'],
|
||||
gotchas_discovered: [],
|
||||
approach_outcome: {
|
||||
success: false,
|
||||
approach_used: 'monkey-patch',
|
||||
why_it_worked: null,
|
||||
why_it_failed: 'Too hacky',
|
||||
alternatives_tried: [],
|
||||
},
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
vi.mocked(parseLLMJson).mockReturnValueOnce(parsedData as unknown as ReturnType<typeof parseLLMJson>);
|
||||
|
||||
const result = await extractSessionInsights(baseConfig({ success: false }));
|
||||
|
||||
expect(result.file_insights[0].file).toBe('src/login.ts');
|
||||
expect(result.patterns_discovered).toContain('MVC');
|
||||
expect(result.approach_outcome.why_it_failed).toBe('Too hacky');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic fallback when both paths fail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns generic insights when result.output is null and parseLLMJson returns null', async () => {
|
||||
mockGenerateText.mockResolvedValue({ output: null, text: 'not valid json' });
|
||||
vi.mocked(parseLLMJson).mockReturnValueOnce(null);
|
||||
|
||||
const result = await extractSessionInsights(baseConfig({ subtaskId: 'sub-fallback', success: false }));
|
||||
|
||||
expect(result.subtask_id).toBe('sub-fallback');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.file_insights).toEqual([]);
|
||||
expect(result.patterns_discovered).toEqual([]);
|
||||
expect(result.gotchas_discovered).toEqual([]);
|
||||
expect(result.recommendations).toEqual([]);
|
||||
expect(result.approach_outcome.approach_used).toContain('sub-fallback');
|
||||
});
|
||||
|
||||
it('returns generic insights when generateText throws', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('API unavailable'));
|
||||
|
||||
const result = await extractSessionInsights(baseConfig({ subtaskId: 'sub-error', success: true }));
|
||||
|
||||
expect(result.subtask_id).toBe('sub-error');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.file_insights).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns generic insights when createSimpleClient throws', async () => {
|
||||
mockCreateSimpleClient.mockRejectedValue(new Error('No credentials'));
|
||||
|
||||
const result = await extractSessionInsights(baseConfig());
|
||||
|
||||
expect(result.subtask_id).toBe('sub-001');
|
||||
expect(result.file_insights).toEqual([]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Never throws
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('never throws — always returns a valid InsightResult', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('catastrophic failure'));
|
||||
|
||||
await expect(extractSessionInsights(baseConfig())).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses haiku model and low thinking level by default', async () => {
|
||||
await extractSessionInsights(baseConfig());
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('accepts custom modelShorthand and thinkingLevel', async () => {
|
||||
await extractSessionInsights(
|
||||
baseConfig({ modelShorthand: 'sonnet', thinkingLevel: 'medium' }),
|
||||
);
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt content validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('includes subtaskId and description in the prompt', async () => {
|
||||
await extractSessionInsights(
|
||||
baseConfig({
|
||||
subtaskId: 'my-task-42',
|
||||
subtaskDescription: 'Fix login regression',
|
||||
}),
|
||||
);
|
||||
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(callArgs.prompt).toContain('my-task-42');
|
||||
expect(callArgs.prompt).toContain('Fix login regression');
|
||||
});
|
||||
|
||||
it('truncates diff when it exceeds 15000 chars', async () => {
|
||||
const longDiff = '+' + 'a'.repeat(20_000);
|
||||
|
||||
await extractSessionInsights(baseConfig({ diff: longDiff }));
|
||||
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
const prompt = callArgs.prompt as string;
|
||||
// The prompt must mention truncation and not contain all 20k chars of diff
|
||||
expect(prompt).toContain('truncated');
|
||||
});
|
||||
|
||||
it('includes changed files in the prompt', async () => {
|
||||
await extractSessionInsights(
|
||||
baseConfig({ changedFiles: ['src/login.ts', 'src/session.ts'] }),
|
||||
);
|
||||
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(callArgs.prompt).toContain('src/login.ts');
|
||||
});
|
||||
|
||||
it('includes attempt history in the prompt when provided', async () => {
|
||||
await extractSessionInsights(
|
||||
baseConfig({
|
||||
attemptHistory: [
|
||||
{ success: false, approach: 'patch method', error: 'type mismatch' },
|
||||
{ success: true, approach: 'full rewrite' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(callArgs.prompt).toContain('patch method');
|
||||
expect(callArgs.prompt).toContain('full rewrite');
|
||||
});
|
||||
|
||||
it('passes output schema configuration to generateText', async () => {
|
||||
await extractSessionInsights(baseConfig());
|
||||
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
// The output key should be set (from Output.object())
|
||||
expect(callArgs).toHaveProperty('output');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockStreamText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
stepCountIs: (n: number) => ({ type: 'stepCount', count: n }),
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// Filesystem mocks — project context files are absent by default
|
||||
const mockExistsSync = vi.fn().mockReturnValue(false);
|
||||
const mockReadFileSync = vi.fn();
|
||||
const mockReaddirSync = vi.fn().mockReturnValue([]);
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
|
||||
}));
|
||||
|
||||
// Mock tool registry
|
||||
vi.mock('../../tools/build-registry', () => ({
|
||||
buildToolRegistry: () => ({
|
||||
getToolsForAgent: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// json-repair is used for safeParseJson in the insights runner
|
||||
vi.mock('../../../utils/json-repair', () => ({
|
||||
safeParseJson: (text: string) => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// parseLLMJson is used for task suggestion extraction
|
||||
vi.mock('../../schema/structured-output', () => ({
|
||||
parseLLMJson: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../../schema/insight-extractor', () => ({
|
||||
TaskSuggestionSchema: {},
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import { runInsightsQuery } from '../insights';
|
||||
import type { InsightsConfig, InsightsStreamEvent } from '../insights';
|
||||
import { parseLLMJson } from '../../schema/structured-output';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const fakeModel = { modelId: 'claude-sonnet-test' };
|
||||
|
||||
function makeMockClient(systemPrompt = 'You are an AI assistant.') {
|
||||
return {
|
||||
model: fakeModel,
|
||||
systemPrompt,
|
||||
tools: {},
|
||||
maxSteps: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStream(parts: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
for (const part of parts) {
|
||||
yield part;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function baseConfig(overrides: Partial<InsightsConfig> = {}): InsightsConfig {
|
||||
return {
|
||||
projectDir: '/project',
|
||||
message: 'How does authentication work?',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('runInsightsQuery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
vi.mocked(parseLLMJson).mockReturnValue(null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Successful run — no streaming events needed from caller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns response text accumulated from stream', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: 'text-delta', text: 'Authentication uses JWT tokens.' },
|
||||
{ type: 'text-delta', text: ' Tokens expire after 1 hour.' },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runInsightsQuery(baseConfig());
|
||||
|
||||
expect(result.text).toBe('Authentication uses JWT tokens. Tokens expire after 1 hour.');
|
||||
expect(result.taskSuggestion).toBeNull();
|
||||
expect(result.toolCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty text and no task suggestion when stream is empty', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
const result = await runInsightsQuery(baseConfig());
|
||||
|
||||
expect(result.text).toBe('');
|
||||
expect(result.taskSuggestion).toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task suggestion extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('extracts task suggestion from response text when marker present', async () => {
|
||||
const suggestion = {
|
||||
title: 'Add rate limiting',
|
||||
description: 'Implement per-user rate limiting on auth endpoints',
|
||||
metadata: { category: 'security', complexity: 'medium', impact: 'high' },
|
||||
};
|
||||
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{
|
||||
type: 'text-delta',
|
||||
text: `Here is my suggestion.\n__TASK_SUGGESTION__:${JSON.stringify(suggestion)}\n`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
vi.mocked(parseLLMJson).mockReturnValueOnce(suggestion as unknown as ReturnType<typeof parseLLMJson>);
|
||||
|
||||
const result = await runInsightsQuery(baseConfig());
|
||||
|
||||
expect(result.taskSuggestion).not.toBeNull();
|
||||
expect(result.taskSuggestion?.title).toBe('Add rate limiting');
|
||||
expect(result.taskSuggestion?.metadata.category).toBe('security');
|
||||
});
|
||||
|
||||
it('returns null taskSuggestion when no marker in response', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([{ type: 'text-delta', text: 'No suggestions here.' }]),
|
||||
);
|
||||
|
||||
const result = await runInsightsQuery(baseConfig());
|
||||
|
||||
expect(result.taskSuggestion).toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool call tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('tracks tool calls in result.toolCalls', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: 'tool-call', toolName: 'Read', toolCallId: 'c1', input: { file_path: 'src/auth.ts' } },
|
||||
{ type: 'tool-result', toolCallId: 'c1', toolName: 'Read', output: 'file content' },
|
||||
{ type: 'tool-call', toolName: 'Glob', toolCallId: 'c2', input: { pattern: '**/*.ts' } },
|
||||
{ type: 'tool-result', toolCallId: 'c2', toolName: 'Glob', output: 'src/auth.ts' },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runInsightsQuery(baseConfig());
|
||||
|
||||
expect(result.toolCalls).toHaveLength(2);
|
||||
expect(result.toolCalls[0].name).toBe('Read');
|
||||
expect(result.toolCalls[1].name).toBe('Glob');
|
||||
});
|
||||
|
||||
it('extracts file_path from Read tool call input', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolName: 'Read',
|
||||
toolCallId: 'c1',
|
||||
input: { file_path: 'src/auth.ts' },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runInsightsQuery(baseConfig());
|
||||
|
||||
expect(result.toolCalls[0].input).toBe('src/auth.ts');
|
||||
});
|
||||
|
||||
it('extracts pattern from Grep/Glob tool call input', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolName: 'Grep',
|
||||
toolCallId: 'c1',
|
||||
input: { pattern: 'useAuth' },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runInsightsQuery(baseConfig());
|
||||
|
||||
expect(result.toolCalls[0].input).toBe('pattern: useAuth');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('forwards text-delta events to onStream callback', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: 'text-delta', text: 'chunk1' },
|
||||
{ type: 'text-delta', text: 'chunk2' },
|
||||
]),
|
||||
);
|
||||
|
||||
const events: InsightsStreamEvent[] = [];
|
||||
await runInsightsQuery(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const textEvents = events.filter((e) => e.type === 'text-delta');
|
||||
expect(textEvents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forwards tool-start events for tool-call stream parts', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: 'tool-call', toolName: 'Grep', toolCallId: 'c1', input: { pattern: 'login' } },
|
||||
]),
|
||||
);
|
||||
|
||||
const events: InsightsStreamEvent[] = [];
|
||||
await runInsightsQuery(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const toolStartEvents = events.filter((e) => e.type === 'tool-start');
|
||||
expect(toolStartEvents).toHaveLength(1);
|
||||
expect((toolStartEvents[0] as { type: 'tool-start'; name: string }).name).toBe('Grep');
|
||||
});
|
||||
|
||||
it('forwards tool-end events for tool-result stream parts', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([
|
||||
{ type: 'tool-result', toolCallId: 'c1', toolName: 'Read', output: 'content' },
|
||||
]),
|
||||
);
|
||||
|
||||
const events: InsightsStreamEvent[] = [];
|
||||
await runInsightsQuery(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const toolEndEvents = events.filter((e) => e.type === 'tool-end');
|
||||
expect(toolEndEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('forwards error events for error stream parts', async () => {
|
||||
mockStreamText.mockReturnValue(
|
||||
makeStream([{ type: 'error', error: new Error('tool failed') }]),
|
||||
);
|
||||
|
||||
const events: InsightsStreamEvent[] = [];
|
||||
await runInsightsQuery(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const errorEvents = events.filter((e) => e.type === 'error');
|
||||
expect(errorEvents).toHaveLength(1);
|
||||
expect((errorEvents[0] as { type: 'error'; error: string }).error).toBe('tool failed');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('rethrows when streamText iteration throws', async () => {
|
||||
mockStreamText.mockReturnValue({
|
||||
// biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path
|
||||
fullStream: (async function* () {
|
||||
throw new Error('API timeout');
|
||||
})(),
|
||||
});
|
||||
|
||||
await expect(runInsightsQuery(baseConfig())).rejects.toThrow('API timeout');
|
||||
});
|
||||
|
||||
it('emits error event to callback before rethrowing', async () => {
|
||||
mockStreamText.mockReturnValue({
|
||||
// biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path
|
||||
fullStream: (async function* () {
|
||||
throw new Error('rate limited');
|
||||
})(),
|
||||
});
|
||||
|
||||
const events: InsightsStreamEvent[] = [];
|
||||
await expect(runInsightsQuery(baseConfig(), (e) => events.push(e))).rejects.toThrow(
|
||||
'rate limited',
|
||||
);
|
||||
|
||||
expect(events.some((e) => e.type === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses sonnet model and medium thinking level by default', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runInsightsQuery(baseConfig());
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
it('accepts custom modelShorthand and thinkingLevel', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runInsightsQuery(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'low' }));
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('includes conversation history in the prompt when provided', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runInsightsQuery(
|
||||
baseConfig({
|
||||
message: 'What about refresh tokens?',
|
||||
history: [
|
||||
{ role: 'user', content: 'How does auth work?' },
|
||||
{ role: 'assistant', content: 'It uses JWT.' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const callArgs = mockStreamText.mock.calls[0][0];
|
||||
const prompt = callArgs.prompt as string;
|
||||
expect(prompt).toContain('How does auth work?');
|
||||
expect(prompt).toContain('It uses JWT.');
|
||||
expect(prompt).toContain('What about refresh tokens?');
|
||||
});
|
||||
|
||||
it('uses message directly as prompt when history is empty', async () => {
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runInsightsQuery(baseConfig({ message: 'What is the entry point?' }));
|
||||
|
||||
const callArgs = mockStreamText.mock.calls[0][0];
|
||||
expect(callArgs.prompt).toBe('What is the entry point?');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockGenerateText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import { resolveMergeConflict, createMergeResolverFn } from '../merge-resolver';
|
||||
import type { MergeResolverConfig } from '../merge-resolver';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const fakeModel = { modelId: 'claude-haiku-test' };
|
||||
|
||||
function makeMockClient(systemPrompt = 'Resolve merge conflicts.') {
|
||||
return { model: fakeModel, systemPrompt };
|
||||
}
|
||||
|
||||
function baseConfig(overrides: Partial<MergeResolverConfig> = {}): MergeResolverConfig {
|
||||
return {
|
||||
systemPrompt: 'You are a merge conflict resolver.',
|
||||
userPrompt: '<<<\nHEAD version\n===\nIncoming version\n>>>',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveMergeConflict', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Successful resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns success with trimmed resolved text', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: ' Resolved: use incoming version. ' });
|
||||
|
||||
const result = await resolveMergeConflict(baseConfig());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toBe('Resolved: use incoming version.');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes model and systemPrompt from client to generateText', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'merged code here' });
|
||||
|
||||
await resolveMergeConflict(baseConfig());
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledOnce();
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(callArgs.model).toBe(fakeModel);
|
||||
expect(callArgs.system).toBe('Resolve merge conflicts.');
|
||||
});
|
||||
|
||||
it('passes userPrompt as the prompt parameter to generateText', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'resolved' });
|
||||
|
||||
const conflict = '<<<\nmy change\n===\ntheir change\n>>>';
|
||||
await resolveMergeConflict(baseConfig({ userPrompt: conflict }));
|
||||
|
||||
const callArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(callArgs.prompt).toBe(conflict);
|
||||
});
|
||||
|
||||
it('passes systemPrompt config to createSimpleClient', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'resolved' });
|
||||
|
||||
const customSystem = 'Custom system prompt.';
|
||||
await resolveMergeConflict(baseConfig({ systemPrompt: customSystem }));
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.systemPrompt).toBe(customSystem);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default model / thinking level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses haiku model and low thinking level by default', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'resolved' });
|
||||
|
||||
await resolveMergeConflict(baseConfig());
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('accepts custom modelShorthand and thinkingLevel', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'resolved' });
|
||||
|
||||
await resolveMergeConflict(
|
||||
baseConfig({ modelShorthand: 'sonnet', thinkingLevel: 'medium' }),
|
||||
);
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty response handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure when LLM returns empty text', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: ' ' });
|
||||
|
||||
const result = await resolveMergeConflict(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.error).toBe('Empty response from AI');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure with error message when generateText throws Error', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('API rate limit'));
|
||||
|
||||
const result = await resolveMergeConflict(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.error).toBe('API rate limit');
|
||||
});
|
||||
|
||||
it('returns failure with string coercion when non-Error is thrown', async () => {
|
||||
mockGenerateText.mockRejectedValue('connection refused');
|
||||
|
||||
const result = await resolveMergeConflict(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('connection refused');
|
||||
});
|
||||
|
||||
it('returns failure when createSimpleClient throws', async () => {
|
||||
mockCreateSimpleClient.mockRejectedValue(new Error('No auth token'));
|
||||
|
||||
const result = await resolveMergeConflict(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No auth token');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createMergeResolverFn
|
||||
// =============================================================================
|
||||
|
||||
describe('createMergeResolverFn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
});
|
||||
|
||||
it('returns an async function', () => {
|
||||
const fn = createMergeResolverFn();
|
||||
expect(typeof fn).toBe('function');
|
||||
});
|
||||
|
||||
it('returned function resolves to the resolved text on success', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'merged content' });
|
||||
|
||||
const fn = createMergeResolverFn();
|
||||
const result = await fn('system context', 'conflict block');
|
||||
|
||||
expect(result).toBe('merged content');
|
||||
});
|
||||
|
||||
it('returned function resolves to empty string when LLM returns empty', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: ' ' });
|
||||
|
||||
const fn = createMergeResolverFn();
|
||||
const result = await fn('system', 'conflict');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('returned function resolves to empty string on error (does not throw)', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
const fn = createMergeResolverFn();
|
||||
const result = await fn('system', 'conflict');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('uses provided modelShorthand and thinkingLevel', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'resolved' });
|
||||
|
||||
const fn = createMergeResolverFn('sonnet', 'medium');
|
||||
await fn('sys', 'user');
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
it('defaults to haiku and low when no arguments given', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'resolved' });
|
||||
|
||||
const fn = createMergeResolverFn();
|
||||
await fn('sys', 'user');
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('passes system and user arguments as systemPrompt and userPrompt', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'resolved' });
|
||||
|
||||
const fn = createMergeResolverFn();
|
||||
await fn('the system prompt', 'the conflict text');
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.systemPrompt).toBe('the system prompt');
|
||||
const generateArgs = mockGenerateText.mock.calls[0][0];
|
||||
expect(generateArgs.prompt).toBe('the conflict text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockStreamText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
stepCountIs: (n: number) => ({ type: 'stepCount', count: n }),
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// Filesystem mocks
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockReadFileSync = vi.fn();
|
||||
const mockWriteFileSync = vi.fn();
|
||||
const mockMkdirSync = vi.fn();
|
||||
const mockRenameSync = vi.fn();
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
||||
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
||||
renameSync: (...args: unknown[]) => mockRenameSync(...args),
|
||||
}));
|
||||
|
||||
// Tool registry mock
|
||||
vi.mock('../../tools/build-registry', () => ({
|
||||
buildToolRegistry: () => ({
|
||||
getToolsForAgent: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// json-repair used for safeParseJson
|
||||
vi.mock('../../../utils/json-repair', () => ({
|
||||
safeParseJson: (text: string) => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// tryLoadPrompt — return null so inline prompts are used
|
||||
vi.mock('../../prompts/prompt-loader', () => ({
|
||||
tryLoadPrompt: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import { runRoadmapGeneration } from '../roadmap';
|
||||
import type { RoadmapConfig, RoadmapStreamEvent } from '../roadmap';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const fakeModel = { modelId: 'claude-sonnet-test' };
|
||||
|
||||
function makeMockClient() {
|
||||
return {
|
||||
model: fakeModel,
|
||||
systemPrompt: '',
|
||||
tools: {},
|
||||
maxSteps: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStream(parts: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
for (const part of parts) {
|
||||
yield part;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Valid discovery JSON that passes schema validation */
|
||||
const VALID_DISCOVERY_JSON = JSON.stringify({
|
||||
project_name: 'TestProject',
|
||||
target_audience: 'Developers',
|
||||
product_vision: 'Make coding easier',
|
||||
key_features: ['Auth', 'Dashboard'],
|
||||
technical_stack: { language: 'TypeScript' },
|
||||
constraints: [],
|
||||
});
|
||||
|
||||
/** Valid roadmap JSON that passes schema validation (>=3 features, all required keys) */
|
||||
const VALID_ROADMAP_JSON = JSON.stringify({
|
||||
vision: 'Automate everything',
|
||||
target_audience: { primary: 'Developers', secondary: 'QA' },
|
||||
phases: [{ id: 'p1', name: 'MVP' }],
|
||||
features: [
|
||||
{
|
||||
id: 'f1', title: 'Feature A', description: 'Desc A', priority: 'high',
|
||||
complexity: 'medium', impact: 'high', phase_id: 'p1', status: 'planned',
|
||||
acceptance_criteria: [], user_stories: [],
|
||||
},
|
||||
{
|
||||
id: 'f2', title: 'Feature B', description: 'Desc B', priority: 'medium',
|
||||
complexity: 'low', impact: 'medium', phase_id: 'p1', status: 'planned',
|
||||
acceptance_criteria: [], user_stories: [],
|
||||
},
|
||||
{
|
||||
id: 'f3', title: 'Feature C', description: 'Desc C', priority: 'low',
|
||||
complexity: 'high', impact: 'low', phase_id: 'p1', status: 'planned',
|
||||
acceptance_criteria: [], user_stories: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function baseConfig(overrides: Partial<RoadmapConfig> = {}): RoadmapConfig {
|
||||
return {
|
||||
projectDir: '/project',
|
||||
outputDir: '/project/.aperant/roadmap',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('runRoadmapGeneration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateSimpleClient.mockResolvedValue(makeMockClient());
|
||||
// Output dir exists by default (created by mkdirSync is a no-op)
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockMkdirSync.mockReturnValue(undefined);
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Successful full pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns success with roadmapPath when both phases succeed', async () => {
|
||||
// existsSync: outputDir does not exist initially; discovery file created after phase 1
|
||||
let discoveryCreated = false;
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap')) return true; // outputDir exists
|
||||
if (p.endsWith('roadmap_discovery.json') && discoveryCreated) return true;
|
||||
if (p.endsWith('roadmap.json') && discoveryCreated) return false; // not yet
|
||||
return false;
|
||||
});
|
||||
|
||||
// streamText yields nothing — validation happens from file reads
|
||||
mockStreamText.mockImplementation(() => {
|
||||
discoveryCreated = true; // simulate file being written during stream
|
||||
return makeStream([]);
|
||||
});
|
||||
|
||||
// readFileSync returns valid JSON for each file
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap_discovery.json')) return VALID_DISCOVERY_JSON;
|
||||
if (p.endsWith('roadmap.json')) return VALID_ROADMAP_JSON;
|
||||
return '{}';
|
||||
});
|
||||
|
||||
// After agent runs, both files exist
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap_discovery.json')) return true;
|
||||
if (p.endsWith('roadmap.json')) return true;
|
||||
return true; // outputDir, etc.
|
||||
});
|
||||
|
||||
const result = await runRoadmapGeneration(baseConfig());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.roadmapPath).toContain('roadmap.json');
|
||||
expect(result.phases).toHaveLength(2);
|
||||
expect(result.phases[0].phase).toBe('discovery');
|
||||
expect(result.phases[1].phase).toBe('features');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery phase failure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure when discovery phase fails after all retries', async () => {
|
||||
// Discovery file is never created
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap')) return true; // outputDir exists
|
||||
return false; // discovery file never appears
|
||||
});
|
||||
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
const result = await runRoadmapGeneration(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Discovery failed');
|
||||
expect(result.phases).toHaveLength(1);
|
||||
expect(result.phases[0].phase).toBe('discovery');
|
||||
expect(result.phases[0].success).toBe(false);
|
||||
});
|
||||
|
||||
it('does not run features phase when discovery fails', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
const result = await runRoadmapGeneration(baseConfig());
|
||||
|
||||
// Only 1 phase in result — features was never attempted
|
||||
expect(result.phases).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Features phase failure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns failure when features phase fails (no roadmap.json created)', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap')) return true;
|
||||
if (p.endsWith('roadmap_discovery.json')) return true; // discovery succeeded
|
||||
if (p.endsWith('project_index.json')) return false;
|
||||
return false; // roadmap.json never created
|
||||
});
|
||||
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap_discovery.json')) return VALID_DISCOVERY_JSON;
|
||||
return '{}';
|
||||
});
|
||||
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
const result = await runRoadmapGeneration(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Feature generation failed');
|
||||
expect(result.phases).toHaveLength(2);
|
||||
expect(result.phases[1].phase).toBe('features');
|
||||
expect(result.phases[1].success).toBe(false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache (refresh=false) — skip phases when files already exist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('skips discovery phase when discovery file already exists and refresh=false', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap')) return true;
|
||||
if (p.endsWith('roadmap_discovery.json')) return true; // already exists
|
||||
if (p.endsWith('roadmap.json')) return true; // also exists
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await runRoadmapGeneration(baseConfig({ refresh: false }));
|
||||
|
||||
// streamText should not have been called since both files exist
|
||||
expect(mockStreamText).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output directory creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('creates output directory when it does not exist', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap') && !p.includes('.json')) return false; // dir does not exist
|
||||
return false;
|
||||
});
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runRoadmapGeneration(baseConfig({ outputDir: '/project/.aperant/roadmap' }));
|
||||
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('roadmap'),
|
||||
expect.objectContaining({ recursive: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('emits phase-start and phase-complete events for both phases', async () => {
|
||||
// Make discovery succeed via cached file
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap')) return true;
|
||||
if (p.endsWith('roadmap_discovery.json')) return true;
|
||||
if (p.endsWith('roadmap.json')) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const events: RoadmapStreamEvent[] = [];
|
||||
await runRoadmapGeneration(baseConfig({ refresh: false }), (e) => events.push(e));
|
||||
|
||||
const phaseStartEvents = events.filter((e) => e.type === 'phase-start');
|
||||
const phaseCompleteEvents = events.filter((e) => e.type === 'phase-complete');
|
||||
|
||||
expect(phaseStartEvents).toHaveLength(2);
|
||||
expect(phaseCompleteEvents).toHaveLength(2);
|
||||
expect((phaseStartEvents[0] as { type: string; phase: string }).phase).toBe('discovery');
|
||||
expect((phaseStartEvents[1] as { type: string; phase: string }).phase).toBe('features');
|
||||
});
|
||||
|
||||
it('forwards text-delta events from stream to callback', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap_discovery.json')) return true;
|
||||
if (p.endsWith('roadmap.json')) return true;
|
||||
return true;
|
||||
});
|
||||
|
||||
const events: RoadmapStreamEvent[] = [];
|
||||
await runRoadmapGeneration(baseConfig({ refresh: false }), (e) => events.push(e));
|
||||
|
||||
// Since files exist and refresh=false, streamText is never called and no text-delta fires
|
||||
// This confirms the caching path works correctly
|
||||
const textEvents = events.filter((e) => e.type === 'text-delta');
|
||||
expect(textEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('forwards text-delta from active streamText run when discovery must be generated', async () => {
|
||||
// outputDir exists, but discovery file does not (first attempt)
|
||||
// After first streamText run, discovery file appears
|
||||
let callCount = 0;
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap') && !p.includes('.json')) return true;
|
||||
if (p.endsWith('roadmap_discovery.json')) return callCount > 0;
|
||||
return false;
|
||||
});
|
||||
|
||||
mockReadFileSync.mockReturnValue(VALID_DISCOVERY_JSON);
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
callCount++;
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield { type: 'text-delta', text: 'Analyzing project...' };
|
||||
})(),
|
||||
};
|
||||
});
|
||||
|
||||
const events: RoadmapStreamEvent[] = [];
|
||||
await runRoadmapGeneration(baseConfig(), (e) => events.push(e));
|
||||
|
||||
const textEvents = events.filter((e) => e.type === 'text-delta');
|
||||
expect(textEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses sonnet and medium thinking level by default', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runRoadmapGeneration(baseConfig());
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('sonnet');
|
||||
expect(clientArgs.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
it('accepts custom modelShorthand and thinkingLevel', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runRoadmapGeneration(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'low' }));
|
||||
|
||||
const clientArgs = mockCreateSimpleClient.mock.calls[0][0];
|
||||
expect(clientArgs.modelShorthand).toBe('haiku');
|
||||
expect(clientArgs.thinkingLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('uses default outputDir when not provided', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockStreamText.mockReturnValue(makeStream([]));
|
||||
|
||||
await runRoadmapGeneration({ projectDir: '/my/project' });
|
||||
|
||||
// mkdirSync should have been called with the default path
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.aperant'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling — streamText throws
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('records error in phase when streamText throws during discovery', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('roadmap') && !p.includes('.json')) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
return {
|
||||
// biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path
|
||||
fullStream: (async function* () {
|
||||
throw new Error('network failure');
|
||||
})(),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await runRoadmapGeneration(baseConfig());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.phases[0].errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -236,10 +236,7 @@ export async function generateCommitMessage(
|
||||
} = config;
|
||||
|
||||
// Find spec directory
|
||||
let specDir = join(projectDir, '.auto-claude', 'specs', specName);
|
||||
if (!existsSync(specDir)) {
|
||||
specDir = join(projectDir, 'auto-claude', 'specs', specName);
|
||||
}
|
||||
const specDir = join(projectDir, '.aperant', 'specs', specName);
|
||||
|
||||
// Get context from spec files
|
||||
const specContext = existsSync(specDir) ? getSpecContext(specDir) : {
|
||||
|
||||
@@ -141,7 +141,7 @@ function gatherPRContext(
|
||||
* Extract a brief summary from the spec file for fallback PR body.
|
||||
*/
|
||||
function extractSpecSummary(projectDir: string, specId: string): string {
|
||||
const specFile = join(projectDir, '.auto-claude', 'specs', specId, 'spec.md');
|
||||
const specFile = join(projectDir, '.aperant', 'specs', specId, 'spec.md');
|
||||
if (!existsSync(specFile)) {
|
||||
return `Implements ${specId}`;
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ ${diffContent}
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
lines.push('_Generated by Auto Claude MR Review_');
|
||||
lines.push('_Generated by Aperant MR Review_');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function runIdeation(
|
||||
const toolContext: ToolContext = {
|
||||
cwd: projectDir,
|
||||
projectDir,
|
||||
specDir: join(projectDir, '.auto-claude', 'specs'),
|
||||
specDir: join(projectDir, '.aperant', 'specs'),
|
||||
securityProfile: null as unknown as SecurityProfile,
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ function loadProjectContext(projectDir: string): string {
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// Load project index if available
|
||||
const indexPath = join(projectDir, '.auto-claude', 'project_index.json');
|
||||
const indexPath = join(projectDir, '.aperant', 'project_index.json');
|
||||
if (existsSync(indexPath)) {
|
||||
const index = safeParseJson<Record<string, unknown>>(readFileSync(indexPath, 'utf-8'));
|
||||
if (index) {
|
||||
@@ -116,7 +116,7 @@ function loadProjectContext(projectDir: string): string {
|
||||
}
|
||||
|
||||
// Load roadmap if available
|
||||
const roadmapPath = join(projectDir, '.auto-claude', 'roadmap', 'roadmap.json');
|
||||
const roadmapPath = join(projectDir, '.aperant', 'roadmap', 'roadmap.json');
|
||||
if (existsSync(roadmapPath)) {
|
||||
const roadmap = safeParseJson<Record<string, unknown>>(readFileSync(roadmapPath, 'utf-8'));
|
||||
if (roadmap) {
|
||||
@@ -132,7 +132,7 @@ function loadProjectContext(projectDir: string): string {
|
||||
}
|
||||
|
||||
// Load existing tasks
|
||||
const tasksPath = join(projectDir, '.auto-claude', 'specs');
|
||||
const tasksPath = join(projectDir, '.aperant', 'specs');
|
||||
if (existsSync(tasksPath)) {
|
||||
try {
|
||||
const taskDirs = readdirSync(tasksPath, { withFileTypes: true })
|
||||
@@ -246,7 +246,7 @@ export async function runInsightsQuery(
|
||||
const toolContext: ToolContext = {
|
||||
cwd: projectDir,
|
||||
projectDir,
|
||||
specDir: join(projectDir, '.auto-claude', 'specs'),
|
||||
specDir: join(projectDir, '.aperant', 'specs'),
|
||||
securityProfile: null as unknown as SecurityProfile,
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ const MAX_STEPS_PER_PHASE = 30;
|
||||
export interface RoadmapConfig {
|
||||
/** Project directory path */
|
||||
projectDir: string;
|
||||
/** Output directory for roadmap files (defaults to .auto-claude/roadmap/) */
|
||||
/** Output directory for roadmap files (defaults to .aperant/roadmap/) */
|
||||
outputDir?: string;
|
||||
/** Model shorthand (defaults to 'sonnet') */
|
||||
modelShorthand?: ModelShorthand;
|
||||
@@ -443,7 +443,7 @@ export async function runRoadmapGeneration(
|
||||
abortSignal,
|
||||
} = config;
|
||||
|
||||
const outputDir = config.outputDir ?? join(projectDir, '.auto-claude', 'roadmap');
|
||||
const outputDir = config.outputDir ?? join(projectDir, '.aperant', 'roadmap');
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(outputDir)) {
|
||||
@@ -454,7 +454,7 @@ export async function runRoadmapGeneration(
|
||||
const toolContext: ToolContext = {
|
||||
cwd: projectDir,
|
||||
projectDir,
|
||||
specDir: join(projectDir, '.auto-claude', 'specs'),
|
||||
specDir: join(projectDir, '.aperant', 'specs'),
|
||||
securityProfile: null as unknown as SecurityProfile,
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function validateAndNormalizeJsonFile<T>(
|
||||
if (result.valid && result.data) {
|
||||
// Write back the coerced data so downstream consumers get canonical field names.
|
||||
// Use a secure temp file + atomic rename to avoid TOCTOU races on the target path.
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'auto-claude-normalize-'));
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'aperant-normalize-'));
|
||||
const tempFile = join(tempDir, 'output.json');
|
||||
try {
|
||||
await writeFile(tempFile, JSON.stringify(result.data, null, 2));
|
||||
@@ -338,7 +338,7 @@ export async function repairJsonWithLLM<T>(
|
||||
const coerced = schema.safeParse(result.output);
|
||||
if (coerced.success) {
|
||||
// Use a secure temp file + atomic rename to avoid TOCTOU races
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'auto-claude-repair-'));
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'aperant-repair-'));
|
||||
const tempFile = join(tempDir, 'output.json');
|
||||
try {
|
||||
await writeFile(tempFile, JSON.stringify(coerced.data, null, 2));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Security Profile Management
|
||||
* ============================
|
||||
*
|
||||
* Loads and caches project security profiles from .auto-claude/ config.
|
||||
* Loads and caches project security profiles from .aperant/ config.
|
||||
* Provides SecurityProfile instances consumed by bash-validator.ts.
|
||||
*
|
||||
* NOTE: With the denylist security model, SecurityProfile command sets are no
|
||||
@@ -23,8 +23,8 @@ import type { SecurityProfile } from './bash-validator';
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROFILE_FILENAME = '.auto-claude-security.json';
|
||||
const ALLOWLIST_FILENAME = '.auto-claude-allowlist';
|
||||
const PROFILE_FILENAME = '.aperant-security.json';
|
||||
const ALLOWLIST_FILENAME = '.aperant-allowlist';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache state
|
||||
|
||||
@@ -86,8 +86,8 @@ const BLOCKED_PROCESS_NAMES = new Set([
|
||||
// -- Self-protection (don't let the agent kill its own host) --
|
||||
'electron',
|
||||
'Electron',
|
||||
'auto-claude',
|
||||
'Auto Claude',
|
||||
'aperant',
|
||||
'Aperant',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('ProgressTracker', () => {
|
||||
type: 'tool-call',
|
||||
toolName: 'Write',
|
||||
toolCallId: 'c1',
|
||||
args: { file_path: '/project/.auto-claude/specs/001/implementation_plan.json' },
|
||||
args: { file_path: '/project/.aperant/specs/001/implementation_plan.json' },
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
@@ -251,12 +251,12 @@ describe('getRequiredMcpServers (registry)', () => {
|
||||
expect(servers).toContain('context7');
|
||||
});
|
||||
|
||||
it('should support per-agent MCP REMOVE overrides but protect auto-claude', () => {
|
||||
it('should support per-agent MCP REMOVE overrides but protect aperant', () => {
|
||||
const servers = getRequiredMcpServers('coder', {
|
||||
memoryEnabled: true,
|
||||
mcpConfig: { AGENT_MCP_coder_REMOVE: 'auto-claude,memory' },
|
||||
mcpConfig: { AGENT_MCP_coder_REMOVE: 'aperant,memory' },
|
||||
});
|
||||
expect(servers).toContain('auto-claude');
|
||||
expect(servers).toContain('aperant');
|
||||
expect(servers).not.toContain('memory');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* =======================
|
||||
*
|
||||
* Reports current build progress from implementation_plan.json.
|
||||
* See apps/desktop/src/main/ai/tools/auto-claude/get-build-progress.ts for the TypeScript implementation.
|
||||
* See apps/desktop/src/main/ai/tools/aperant/get-build-progress.ts for the TypeScript implementation.
|
||||
*
|
||||
* Tool name: mcp__auto-claude__get_build_progress
|
||||
* Tool name: mcp__aperant__get_build_progress
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
@@ -50,7 +50,7 @@ interface ImplementationPlan {
|
||||
|
||||
export const getBuildProgressTool = Tool.define({
|
||||
metadata: {
|
||||
name: 'mcp__auto-claude__get_build_progress',
|
||||
name: 'mcp__aperant__get_build_progress',
|
||||
description:
|
||||
'Get the current build progress including completed subtasks, pending subtasks, and next subtask to work on.',
|
||||
permission: ToolPermission.ReadOnly,
|
||||
@@ -7,9 +7,9 @@
|
||||
* - memory/gotchas.md → gotchas & pitfalls
|
||||
* - memory/patterns.md → code patterns
|
||||
*
|
||||
* See apps/desktop/src/main/ai/tools/auto-claude/get-session-context.ts for the TypeScript implementation.
|
||||
* See apps/desktop/src/main/ai/tools/aperant/get-session-context.ts for the TypeScript implementation.
|
||||
*
|
||||
* Tool name: mcp__auto-claude__get_session_context
|
||||
* Tool name: mcp__aperant__get_session_context
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
@@ -40,7 +40,7 @@ interface CodebaseMap {
|
||||
|
||||
export const getSessionContextTool = Tool.define({
|
||||
metadata: {
|
||||
name: 'mcp__auto-claude__get_session_context',
|
||||
name: 'mcp__aperant__get_session_context',
|
||||
description:
|
||||
'Get context from previous sessions including codebase discoveries, gotchas, and patterns. Call this at the start of a session to pick up where the last session left off.',
|
||||
permission: ToolPermission.ReadOnly,
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Auto-Claude Custom Tools
|
||||
* ========================
|
||||
* Aperant Custom Tools
|
||||
* ====================
|
||||
*
|
||||
* Barrel export for all auto-claude builtin tools.
|
||||
* Barrel export for all aperant builtin tools.
|
||||
* These replace the Python tools_pkg/tools/* implementations.
|
||||
*
|
||||
* Tool names follow the mcp__auto-claude__* convention to match the
|
||||
* Tool names follow the mcp__aperant__* convention to match the
|
||||
* TOOL_* constants in registry.ts and AGENT_CONFIGS autoClaudeTools arrays.
|
||||
*/
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* =====================
|
||||
*
|
||||
* Records a codebase discovery to session memory (codebase_map.json).
|
||||
* See apps/desktop/src/main/ai/tools/auto-claude/record-discovery.ts for the TypeScript implementation.
|
||||
* See apps/desktop/src/main/ai/tools/aperant/record-discovery.ts for the TypeScript implementation.
|
||||
*
|
||||
* Tool name: mcp__auto-claude__record_discovery
|
||||
* Tool name: mcp__aperant__record_discovery
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
@@ -44,7 +44,7 @@ interface CodebaseMap {
|
||||
|
||||
export const recordDiscoveryTool = Tool.define({
|
||||
metadata: {
|
||||
name: 'mcp__auto-claude__record_discovery',
|
||||
name: 'mcp__aperant__record_discovery',
|
||||
description:
|
||||
'Record a codebase discovery to session memory. Use this when you learn something important about the codebase structure or behavior.',
|
||||
permission: ToolPermission.Auto,
|
||||
@@ -3,9 +3,9 @@
|
||||
* ==================
|
||||
*
|
||||
* Records a gotcha or pitfall to specDir/memory/gotchas.md.
|
||||
* See apps/desktop/src/main/ai/tools/auto-claude/record-gotcha.ts for the TypeScript implementation.
|
||||
* See apps/desktop/src/main/ai/tools/aperant/record-gotcha.ts for the TypeScript implementation.
|
||||
*
|
||||
* Tool name: mcp__auto-claude__record_gotcha
|
||||
* Tool name: mcp__aperant__record_gotcha
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
@@ -33,7 +33,7 @@ const inputSchema = z.object({
|
||||
|
||||
export const recordGotchaTool = Tool.define({
|
||||
metadata: {
|
||||
name: 'mcp__auto-claude__record_gotcha',
|
||||
name: 'mcp__aperant__record_gotcha',
|
||||
description:
|
||||
'Record a gotcha or pitfall to avoid. Use this when you encounter something that future sessions should know about to avoid repeating mistakes.',
|
||||
permission: ToolPermission.Auto,
|
||||
@@ -3,9 +3,9 @@
|
||||
* =====================
|
||||
*
|
||||
* Updates the QA sign-off status in implementation_plan.json.
|
||||
* See apps/desktop/src/main/ai/tools/auto-claude/update-qa-status.ts for the TypeScript implementation.
|
||||
* See apps/desktop/src/main/ai/tools/aperant/update-qa-status.ts for the TypeScript implementation.
|
||||
*
|
||||
* Tool name: mcp__auto-claude__update_qa_status
|
||||
* Tool name: mcp__aperant__update_qa_status
|
||||
*
|
||||
* IMPORTANT: Do NOT write plan["status"] or plan["planStatus"] here.
|
||||
* The frontend XState task state machine owns status transitions.
|
||||
@@ -68,7 +68,7 @@ interface ImplementationPlan {
|
||||
|
||||
export const updateQaStatusTool = Tool.define({
|
||||
metadata: {
|
||||
name: 'mcp__auto-claude__update_qa_status',
|
||||
name: 'mcp__aperant__update_qa_status',
|
||||
description:
|
||||
'Update the QA sign-off status in implementation_plan.json. Use this after completing a QA review to record the outcome.',
|
||||
permission: ToolPermission.Auto,
|
||||
@@ -3,9 +3,9 @@
|
||||
* ==========================
|
||||
*
|
||||
* Updates the status of a subtask in implementation_plan.json.
|
||||
* See apps/desktop/src/main/ai/tools/auto-claude/update-subtask-status.ts for the TypeScript implementation.
|
||||
* See apps/desktop/src/main/ai/tools/aperant/update-subtask-status.ts for the TypeScript implementation.
|
||||
*
|
||||
* Tool name: mcp__auto-claude__update_subtask_status
|
||||
* Tool name: mcp__aperant__update_subtask_status
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
@@ -82,7 +82,7 @@ function updateSubtaskInPlan(
|
||||
|
||||
export const updateSubtaskStatusTool = Tool.define({
|
||||
metadata: {
|
||||
name: 'mcp__auto-claude__update_subtask_status',
|
||||
name: 'mcp__aperant__update_subtask_status',
|
||||
description:
|
||||
'Update the status of a subtask in implementation_plan.json. Use this when completing or starting a subtask.',
|
||||
permission: ToolPermission.Auto,
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { bashTool } from '../bash';
|
||||
import type { ToolContext } from '../../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockExecFile = vi.fn();
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: (...args: unknown[]) => mockExecFile(...args),
|
||||
}));
|
||||
|
||||
const mockIsWindows = vi.fn(() => false);
|
||||
const mockFindExecutable = vi.fn(() => null);
|
||||
const mockKillProcessGracefully = vi.fn();
|
||||
|
||||
vi.mock('../../../../platform/index', () => ({
|
||||
isWindows: () => mockIsWindows(),
|
||||
findExecutable: (_name: string, _additionalPaths?: string[]) => mockFindExecutable(),
|
||||
killProcessGracefully: (_childProcess: unknown, _options?: unknown) => mockKillProcessGracefully(),
|
||||
}));
|
||||
|
||||
const mockBashSecurityHook = vi.fn(() => ({}));
|
||||
vi.mock('../../../security/bash-validator', () => ({
|
||||
bashSecurityHook: (_input: unknown, _profile?: unknown) => mockBashSecurityHook(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const baseContext: ToolContext = {
|
||||
cwd: '/test/project',
|
||||
projectDir: '/test/project',
|
||||
specDir: '/test/specs/001',
|
||||
securityProfile: {
|
||||
baseCommands: new Set(),
|
||||
stackCommands: new Set(),
|
||||
scriptCommands: new Set(),
|
||||
customCommands: new Set(),
|
||||
customScripts: { shellScripts: [] },
|
||||
getAllAllowedCommands: () => new Set(),
|
||||
},
|
||||
} as unknown as ToolContext;
|
||||
|
||||
/**
|
||||
* Set up mockExecFile to invoke the callback with the provided values.
|
||||
*/
|
||||
function setupExecFile(stdout: string, stderr: string, exitCode: number) {
|
||||
mockExecFile.mockImplementation(
|
||||
(_shell: unknown, _args: unknown, _opts: unknown, callback: (err: Error | null, stdout: string, stderr: string) => void) => {
|
||||
const err = exitCode !== 0 ? Object.assign(new Error('exit'), { code: exitCode }) : null;
|
||||
callback(err, stdout, stderr);
|
||||
return { pid: 1234 };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Bash Tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
mockBashSecurityHook.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(bashTool.metadata.name).toBe('Bash');
|
||||
expect(bashTool.metadata.permission).toBe('requires_approval');
|
||||
});
|
||||
|
||||
it('should return stdout from successful command', async () => {
|
||||
setupExecFile('hello from bash\n', '', 0);
|
||||
|
||||
const result = await bashTool.config.execute(
|
||||
{ command: 'echo hello from bash' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('hello from bash');
|
||||
});
|
||||
|
||||
it('should include stderr in output when present', async () => {
|
||||
setupExecFile('', 'some warning\n', 0);
|
||||
|
||||
const result = await bashTool.config.execute(
|
||||
{ command: 'cmd-with-stderr' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('STDERR:');
|
||||
expect(result).toContain('some warning');
|
||||
});
|
||||
|
||||
it('should include exit code in output when non-zero', async () => {
|
||||
setupExecFile('', '', 1);
|
||||
|
||||
const result = await bashTool.config.execute(
|
||||
{ command: 'failing-command' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Exit code: 1');
|
||||
});
|
||||
|
||||
it('should return (no output) when stdout and stderr are empty and exit code is 0', async () => {
|
||||
setupExecFile('', '', 0);
|
||||
|
||||
const result = await bashTool.config.execute(
|
||||
{ command: 'silent-command' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toBe('(no output)');
|
||||
});
|
||||
|
||||
it('should truncate output exceeding MAX_OUTPUT_LENGTH', async () => {
|
||||
const longOutput = 'x'.repeat(31_000);
|
||||
setupExecFile(longOutput, '', 0);
|
||||
|
||||
const result = await bashTool.config.execute(
|
||||
{ command: 'long-output-cmd' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('[Output truncated');
|
||||
expect(result.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
|
||||
it('should return error message when security hook rejects command', async () => {
|
||||
mockBashSecurityHook.mockReturnValue({
|
||||
hookSpecificOutput: {
|
||||
permissionDecisionReason: 'command is blocked for safety',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await bashTool.config.execute(
|
||||
{ command: 'rm -rf /' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Error: Command not allowed');
|
||||
expect(result).toContain('command is blocked for safety');
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start command in background and return immediately', async () => {
|
||||
// In background mode the execute call is fire-and-forget, so mockExecFile
|
||||
// may or may not be called synchronously. The return value is what matters.
|
||||
mockExecFile.mockImplementation(
|
||||
(_shell: unknown, _args: unknown, _opts: unknown, _callback: unknown) => {
|
||||
return { pid: 5678 };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await bashTool.config.execute(
|
||||
{ command: 'sleep 100', run_in_background: true },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Command started in background');
|
||||
expect(result).toContain('sleep 100');
|
||||
});
|
||||
|
||||
it('should pass cwd from context to execFile', async () => {
|
||||
setupExecFile('output', '', 0);
|
||||
|
||||
await bashTool.config.execute(
|
||||
{ command: 'pwd' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ cwd: '/test/project' }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap timeout to MAX_TIMEOUT_MS (600000)', async () => {
|
||||
setupExecFile('output', '', 0);
|
||||
|
||||
await bashTool.config.execute(
|
||||
{ command: 'cmd', timeout: 9_000_000 },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ timeout: 600_000 }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use /bin/bash as shell on non-Windows', async () => {
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
setupExecFile('output', '', 0);
|
||||
|
||||
await bashTool.config.execute(
|
||||
{ command: 'echo hi' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'/bin/bash',
|
||||
['-c', 'echo hi'],
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use cmd.exe args (/c) on Windows when bash not found', async () => {
|
||||
// The Windows branch uses /c rather than -c for cmd.exe.
|
||||
// We verify the logic by checking that bash uses -c on non-Windows (already tested
|
||||
// above) and that the findExecutable mock would select the right executable.
|
||||
// This test validates the cmd.exe ComSpec fallback resolution path.
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockFindExecutable.mockReturnValue(null);
|
||||
|
||||
const origComSpec = process.env.ComSpec;
|
||||
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
|
||||
|
||||
setupExecFile('output', '', 0);
|
||||
|
||||
await bashTool.config.execute(
|
||||
{ command: 'dir' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
// Verify that on Windows with no bash found, cmd.exe with /c flag is used
|
||||
const callArgs = mockExecFile.mock.calls[0];
|
||||
const shell = callArgs[0] as string;
|
||||
const args = callArgs[1] as string[];
|
||||
|
||||
// The shell should be cmd.exe (via ComSpec) and arg should be /c
|
||||
expect(shell).toBe('C:\\Windows\\System32\\cmd.exe');
|
||||
expect(args[0]).toBe('/c');
|
||||
expect(args[1]).toBe('dir');
|
||||
|
||||
process.env.ComSpec = origComSpec;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { editTool } from '../edit';
|
||||
import type { ToolContext } from '../../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('../../../security/path-containment', () => ({
|
||||
assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({
|
||||
contained: true,
|
||||
resolvedPath: _filePath,
|
||||
})),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { assertPathContained } from '../../../security/path-containment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const baseContext: ToolContext = {
|
||||
cwd: '/test/project',
|
||||
projectDir: '/test/project',
|
||||
specDir: '/test/specs/001',
|
||||
securityProfile: {
|
||||
baseCommands: new Set(),
|
||||
stackCommands: new Set(),
|
||||
scriptCommands: new Set(),
|
||||
customCommands: new Set(),
|
||||
customScripts: { shellScripts: [] },
|
||||
getAllAllowedCommands: () => new Set(),
|
||||
},
|
||||
} as unknown as ToolContext;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Edit Tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({
|
||||
contained: true,
|
||||
resolvedPath: _filePath,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(editTool.metadata.name).toBe('Edit');
|
||||
expect(editTool.metadata.permission).toBe('requires_approval');
|
||||
});
|
||||
|
||||
it('should successfully replace a single occurrence', async () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('hello world foo bar');
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
const result = await editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/file.ts',
|
||||
old_string: 'hello world',
|
||||
new_string: 'goodbye world',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Successfully edited');
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/test/project/file.ts',
|
||||
'goodbye world foo bar',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace all occurrences when replace_all is true', async () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('foo bar foo baz foo');
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
const result = await editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/file.ts',
|
||||
old_string: 'foo',
|
||||
new_string: 'qux',
|
||||
replace_all: true,
|
||||
},
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Successfully replaced 3 occurrence(s)');
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/test/project/file.ts',
|
||||
'qux bar qux baz qux',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when old_string not found in file', async () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('some other content');
|
||||
|
||||
const result = await editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/file.ts',
|
||||
old_string: 'nonexistent text',
|
||||
new_string: 'replacement',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Error: old_string not found');
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when old_string matches multiple locations without replace_all', async () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('foo foo foo');
|
||||
|
||||
const result = await editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/file.ts',
|
||||
old_string: 'foo',
|
||||
new_string: 'bar',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Error: old_string appears 3 times');
|
||||
expect(result).toContain('replace_all: true');
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when old_string equals new_string', async () => {
|
||||
const result = await editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/file.ts',
|
||||
old_string: 'same text',
|
||||
new_string: 'same text',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Error: old_string and new_string are identical');
|
||||
expect(fs.readFileSync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when file not found', async () => {
|
||||
const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => { throw enoentError; });
|
||||
|
||||
const result = await editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/missing.ts',
|
||||
old_string: 'old',
|
||||
new_string: 'new',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Error: File not found');
|
||||
});
|
||||
|
||||
it('should throw non-ENOENT filesystem errors', async () => {
|
||||
const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => { throw permError; });
|
||||
|
||||
await expect(
|
||||
editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/file.ts',
|
||||
old_string: 'old',
|
||||
new_string: 'new',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
),
|
||||
).rejects.toThrow('EACCES');
|
||||
});
|
||||
|
||||
it('should call assertPathContained for path security', async () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('hello world');
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
await editTool.config.execute(
|
||||
{
|
||||
file_path: '/test/project/file.ts',
|
||||
old_string: 'hello world',
|
||||
new_string: 'goodbye world',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(assertPathContained).toHaveBeenCalledWith('/test/project/file.ts', '/test/project');
|
||||
});
|
||||
|
||||
it('should throw when path is outside project boundary', async () => {
|
||||
vi.mocked(assertPathContained).mockImplementation(() => {
|
||||
throw new Error("Path '/etc/passwd' is outside the project directory");
|
||||
});
|
||||
|
||||
await expect(
|
||||
editTool.config.execute(
|
||||
{
|
||||
file_path: '/etc/passwd',
|
||||
old_string: 'root',
|
||||
new_string: 'hacked',
|
||||
replace_all: false,
|
||||
},
|
||||
baseContext,
|
||||
),
|
||||
).rejects.toThrow('outside the project directory');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { globTool } from '../glob';
|
||||
import type { ToolContext } from '../../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('../../../security/path-containment', () => ({
|
||||
assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({
|
||||
contained: true,
|
||||
resolvedPath: _filePath,
|
||||
})),
|
||||
}));
|
||||
vi.mock('../../truncation', () => ({
|
||||
truncateToolOutput: vi.fn((output: string) => ({
|
||||
content: output,
|
||||
wasTruncated: false,
|
||||
originalSize: Buffer.byteLength(output, 'utf-8'),
|
||||
})),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { assertPathContained } from '../../../security/path-containment';
|
||||
import { truncateToolOutput } from '../../truncation';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const baseContext: ToolContext = {
|
||||
cwd: '/test/project',
|
||||
projectDir: '/test/project',
|
||||
specDir: '/test/specs/001',
|
||||
securityProfile: {
|
||||
baseCommands: new Set(),
|
||||
stackCommands: new Set(),
|
||||
scriptCommands: new Set(),
|
||||
customCommands: new Set(),
|
||||
customScripts: { shellScripts: [] },
|
||||
getAllAllowedCommands: () => new Set(),
|
||||
},
|
||||
} as unknown as ToolContext;
|
||||
|
||||
/**
|
||||
* Configure fs mocks for a glob run that returns the given absolute paths.
|
||||
* Each path gets a fake mtime so sorting can be tested.
|
||||
*/
|
||||
function setupGlobMatches(absolutePaths: string[], mtimes?: number[]) {
|
||||
// existsSync for the search dir
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
// globSync returns relative filenames that the tool will resolve
|
||||
const relPaths = absolutePaths.map((p) => p.replace('/test/project/', ''));
|
||||
vi.mocked(fs.globSync).mockReturnValue(relPaths);
|
||||
|
||||
// statSync used twice: once to check isFile, once to get mtime
|
||||
let callIdx = 0;
|
||||
vi.mocked(fs.statSync).mockImplementation((_p) => {
|
||||
const mtime = mtimes ? mtimes[callIdx % mtimes.length] : 1000;
|
||||
callIdx++;
|
||||
return {
|
||||
isFile: () => true,
|
||||
mtimeMs: mtime,
|
||||
} as unknown as fs.Stats;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Glob Tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({
|
||||
contained: true,
|
||||
resolvedPath: _filePath,
|
||||
}));
|
||||
vi.mocked(truncateToolOutput).mockImplementation((output: string) => ({
|
||||
content: output,
|
||||
wasTruncated: false,
|
||||
originalSize: Buffer.byteLength(output, 'utf-8'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(globTool.metadata.name).toBe('Glob');
|
||||
expect(globTool.metadata.permission).toBe('read_only');
|
||||
});
|
||||
|
||||
it('should return matching file paths', async () => {
|
||||
setupGlobMatches([
|
||||
'/test/project/src/index.ts',
|
||||
'/test/project/src/utils.ts',
|
||||
]);
|
||||
|
||||
const result = await globTool.config.execute(
|
||||
{ pattern: '**/*.ts' },
|
||||
baseContext,
|
||||
) as string;
|
||||
|
||||
expect(result).toContain('index.ts');
|
||||
expect(result).toContain('utils.ts');
|
||||
});
|
||||
|
||||
it('should return "No files found" when pattern matches nothing', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.globSync).mockReturnValue([]);
|
||||
|
||||
const result = await globTool.config.execute(
|
||||
{ pattern: '**/*.nonexistent' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toBe('No files found');
|
||||
});
|
||||
|
||||
it('should return error when search directory does not exist', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const result = await globTool.config.execute(
|
||||
{ pattern: '*.ts', path: '/test/project/missing-dir' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toContain('Error: Directory not found');
|
||||
});
|
||||
|
||||
it('should sort results by mtime (most recent first)', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.globSync).mockReturnValue(['old.ts', 'new.ts', 'middle.ts']);
|
||||
|
||||
// Return different mtimes for isFile check vs mtime check
|
||||
// statSync is called once per file for isFile and once per file for mtime
|
||||
const mtimes: Record<string, number> = {
|
||||
[path.resolve('/test/project', 'old.ts')]: 1000,
|
||||
[path.resolve('/test/project', 'new.ts')]: 3000,
|
||||
[path.resolve('/test/project', 'middle.ts')]: 2000,
|
||||
};
|
||||
|
||||
vi.mocked(fs.statSync).mockImplementation((p) => ({
|
||||
isFile: () => true,
|
||||
mtimeMs: mtimes[p as string] ?? 1000,
|
||||
} as unknown as fs.Stats));
|
||||
|
||||
const result = await globTool.config.execute(
|
||||
{ pattern: '*.ts' },
|
||||
baseContext,
|
||||
) as string;
|
||||
|
||||
const lines = result.split('\n');
|
||||
const newIdx = lines.findIndex((l) => l.includes('new.ts'));
|
||||
const middleIdx = lines.findIndex((l) => l.includes('middle.ts'));
|
||||
const oldIdx = lines.findIndex((l) => l.includes('old.ts'));
|
||||
|
||||
expect(newIdx).toBeLessThan(middleIdx);
|
||||
expect(middleIdx).toBeLessThan(oldIdx);
|
||||
});
|
||||
|
||||
it('should use provided path instead of cwd when given', async () => {
|
||||
setupGlobMatches(['/test/project/sub/file.ts']);
|
||||
|
||||
await globTool.config.execute(
|
||||
{ pattern: '*.ts', path: '/test/project/sub' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(fs.globSync).toHaveBeenCalledWith('*.ts', expect.objectContaining({
|
||||
cwd: '/test/project/sub',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should exclude node_modules and .git from results', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.globSync).mockReturnValue(['src/index.ts']);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isFile: () => true,
|
||||
mtimeMs: 1000,
|
||||
} as unknown as fs.Stats);
|
||||
|
||||
await globTool.config.execute(
|
||||
{ pattern: '**/*.ts' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
// The exclude function passed to globSync should exclude node_modules/.git
|
||||
const globSyncCall = vi.mocked(fs.globSync).mock.calls[0];
|
||||
const opts = globSyncCall[1] as { exclude?: (name: string) => boolean };
|
||||
expect(opts.exclude).toBeDefined();
|
||||
expect(opts.exclude?.('node_modules')).toBe(true);
|
||||
expect(opts.exclude?.('.git')).toBe(true);
|
||||
expect(opts.exclude?.('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('should call assertPathContained for path security', async () => {
|
||||
setupGlobMatches([]);
|
||||
vi.mocked(fs.globSync).mockReturnValue([]);
|
||||
|
||||
await globTool.config.execute(
|
||||
{ pattern: '*.ts' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(assertPathContained).toHaveBeenCalledWith('/test/project', '/test/project');
|
||||
});
|
||||
|
||||
it('should pass output through truncateToolOutput', async () => {
|
||||
setupGlobMatches(['/test/project/a.ts']);
|
||||
|
||||
await globTool.config.execute(
|
||||
{ pattern: '*.ts' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(truncateToolOutput).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { grepTool } from '../grep';
|
||||
import type { ToolContext } from '../../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockExecFile = vi.fn();
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: (...args: unknown[]) => mockExecFile(...args),
|
||||
}));
|
||||
|
||||
const mockFindExecutable = vi.fn(() => '/usr/bin/rg');
|
||||
|
||||
vi.mock('../../../../platform/index', () => ({
|
||||
findExecutable: (_name: string, _additionalPaths?: string[]) => mockFindExecutable(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../security/path-containment', () => ({
|
||||
assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({
|
||||
contained: true,
|
||||
resolvedPath: _filePath,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { assertPathContained } from '../../../security/path-containment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const baseContext: ToolContext = {
|
||||
cwd: '/test/project',
|
||||
projectDir: '/test/project',
|
||||
specDir: '/test/specs/001',
|
||||
securityProfile: {
|
||||
baseCommands: new Set(),
|
||||
stackCommands: new Set(),
|
||||
scriptCommands: new Set(),
|
||||
customCommands: new Set(),
|
||||
customScripts: { shellScripts: [] },
|
||||
getAllAllowedCommands: () => new Set(),
|
||||
},
|
||||
} as unknown as ToolContext;
|
||||
|
||||
/**
|
||||
* Set up mockExecFile to invoke the callback with the provided rg output values.
|
||||
*/
|
||||
function setupRg(stdout: string, stderr: string, exitCode: number) {
|
||||
mockExecFile.mockImplementation(
|
||||
(
|
||||
_rgPath: unknown,
|
||||
_args: unknown,
|
||||
_opts: unknown,
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
const err = exitCode !== 0 ? Object.assign(new Error('exit'), { code: exitCode }) : null;
|
||||
callback(err, stdout, stderr);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Grep Tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Re-set after clearAllMocks wipes the return value
|
||||
mockFindExecutable.mockReturnValue('/usr/bin/rg');
|
||||
vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({
|
||||
contained: true,
|
||||
resolvedPath: _filePath,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(grepTool.metadata.name).toBe('Grep');
|
||||
expect(grepTool.metadata.permission).toBe('read_only');
|
||||
});
|
||||
|
||||
it('should return matching files in files_with_matches mode (default)', async () => {
|
||||
setupRg('/test/project/src/index.ts\n/test/project/src/utils.ts\n', '', 0);
|
||||
|
||||
const result = await grepTool.config.execute(
|
||||
{ pattern: 'myFunction' },
|
||||
baseContext,
|
||||
) as string;
|
||||
|
||||
expect(result).toContain('/test/project/src/index.ts');
|
||||
expect(result).toContain('/test/project/src/utils.ts');
|
||||
});
|
||||
|
||||
it('should return "No matches found" when rg exits with code 1 and no stderr', async () => {
|
||||
setupRg('', '', 1);
|
||||
|
||||
const result = await grepTool.config.execute(
|
||||
{ pattern: 'nonexistent_pattern_xyz' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toBe('No matches found');
|
||||
});
|
||||
|
||||
it('should return "No matches found" when stdout is empty', async () => {
|
||||
setupRg(' \n', '', 0);
|
||||
|
||||
const result = await grepTool.config.execute(
|
||||
{ pattern: 'something' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(result).toBe('No matches found');
|
||||
});
|
||||
|
||||
it('should return error message when rg exits with code > 1 and stderr', async () => {
|
||||
setupRg('', 'rg: error: unknown file type\n', 2);
|
||||
|
||||
const result = await grepTool.config.execute(
|
||||
{ pattern: 'test', type: 'unknowntype' },
|
||||
baseContext,
|
||||
) as string;
|
||||
|
||||
expect(result).toContain('Error:');
|
||||
expect(result).toContain('unknown file type');
|
||||
});
|
||||
|
||||
it('should return error when ripgrep is not installed', async () => {
|
||||
mockFindExecutable.mockReturnValue(null as unknown as string);
|
||||
|
||||
const result = await grepTool.config.execute(
|
||||
{ pattern: 'test' },
|
||||
baseContext,
|
||||
) as string;
|
||||
|
||||
expect(result).toContain('Error:');
|
||||
expect(result).toContain('ripgrep');
|
||||
});
|
||||
|
||||
it('should include --files-with-matches flag in default mode', async () => {
|
||||
setupRg('/test/project/a.ts\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--files-with-matches');
|
||||
});
|
||||
|
||||
it('should include --line-number flag in content mode', async () => {
|
||||
setupRg('src/a.ts:10:const hello = 1;\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello', output_mode: 'content' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--line-number');
|
||||
expect(args).not.toContain('--files-with-matches');
|
||||
expect(args).not.toContain('--count');
|
||||
});
|
||||
|
||||
it('should include --count flag in count mode', async () => {
|
||||
setupRg('src/a.ts:5\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello', output_mode: 'count' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--count');
|
||||
});
|
||||
|
||||
it('should add -C flag when context lines are specified in content mode', async () => {
|
||||
setupRg('match output\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello', output_mode: 'content', context: 3 },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('-C');
|
||||
expect(args).toContain('3');
|
||||
});
|
||||
|
||||
it('should add --type flag when type is specified', async () => {
|
||||
setupRg('/test/project/a.ts\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello', type: 'ts' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--type');
|
||||
expect(args).toContain('ts');
|
||||
});
|
||||
|
||||
it('should add --glob flag when glob is specified', async () => {
|
||||
setupRg('/test/project/src/a.ts\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello', glob: '*.{ts,tsx}' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--glob');
|
||||
expect(args).toContain('*.{ts,tsx}');
|
||||
});
|
||||
|
||||
it('should truncate output exceeding MAX_OUTPUT_LENGTH', async () => {
|
||||
const longOutput = '/test/project/file.ts\n'.repeat(2000);
|
||||
setupRg(longOutput, '', 0);
|
||||
|
||||
const result = await grepTool.config.execute(
|
||||
{ pattern: 'test' },
|
||||
baseContext,
|
||||
) as string;
|
||||
|
||||
expect(result).toContain('[Output truncated');
|
||||
expect(result.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
|
||||
it('should call assertPathContained for path security', async () => {
|
||||
setupRg('/test/project/a.ts\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
expect(assertPathContained).toHaveBeenCalledWith('/test/project', '/test/project');
|
||||
});
|
||||
|
||||
it('should throw when search path is outside project boundary', async () => {
|
||||
vi.mocked(assertPathContained).mockImplementation(() => {
|
||||
throw new Error("Path '/etc' is outside the project directory");
|
||||
});
|
||||
|
||||
await expect(
|
||||
grepTool.config.execute(
|
||||
{ pattern: 'root', path: '/etc' },
|
||||
baseContext,
|
||||
),
|
||||
).rejects.toThrow('outside the project directory');
|
||||
});
|
||||
|
||||
it('should use provided path for search instead of cwd', async () => {
|
||||
setupRg('/test/project/sub/a.ts\n', '', 0);
|
||||
|
||||
await grepTool.config.execute(
|
||||
{ pattern: 'hello', path: '/test/project/sub' },
|
||||
baseContext,
|
||||
);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
// The resolved search path should be the last argument before the pattern
|
||||
expect(args).toContain('/test/project/sub');
|
||||
});
|
||||
});
|
||||