diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3341e03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test + + - name: Coverage summary + run: npm run test:coverage -- --top=15 diff --git a/README.md b/README.md index 4d3092d..502e759 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,8 @@ concierge/ └── docs/ ├── ARCHITECTURE.md # System architecture ├── FEATURES.md # Feature inventory + ├── testing/ # Testing and coverage docs + │ └── COVERAGE_BASELINE.md └── REFERENCE.md # Developer quick reference ``` @@ -187,6 +189,8 @@ concierge/ ```bash npm test # Run unit tests npm run lint # Run ESLint +npm run test:coverage # Run tests with coverage + summary +npm run test:coverage -- --line-min=45 # Optional coverage threshold gate ``` ## Documentation @@ -194,6 +198,7 @@ npm run lint # Run ESLint - [Architecture Guide](docs/ARCHITECTURE.md) - Detailed system design, data flow, APIs, and component breakdown - [Feature Catalog](docs/FEATURES.md) - Canonical list of user-facing capabilities - [Developer Reference](docs/REFERENCE.md) - Quick-reference for file map, data models, functions, and common patterns +- [Coverage Baseline](docs/testing/COVERAGE_BASELINE.md) - Current coverage snapshot and milestone targets ## License diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 5f687cc..70aa205 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -1,5 +1,14 @@ # Developer Quick Reference +## Testing Commands + +```bash +npm test +npm run lint +npm run test:coverage +npm run test:coverage -- --line-min=45 --branch-min=65 --func-min=50 +``` + ## File Structure ### Backend diff --git a/docs/testing/COVERAGE_BASELINE.md b/docs/testing/COVERAGE_BASELINE.md new file mode 100644 index 0000000..410a077 --- /dev/null +++ b/docs/testing/COVERAGE_BASELINE.md @@ -0,0 +1,57 @@ +# Coverage Baseline + +Last measured with: + +```bash +npm run test:coverage +``` + +## Baseline Snapshot + +- Date: 2026-02-28 +- Line coverage: `41.79%` +- Branch coverage: `69.55%` +- Function coverage: `51.35%` + +## Risk-First Milestones + +### Milestone 1: Routes + Providers + +Primary targets: + +- `lib/routes/conversations.js` +- `lib/routes/files.js` +- `lib/routes/git.js` +- `lib/routes/preview.js` +- `lib/routes/workflow.js` +- `lib/providers/claude.js` +- `lib/providers/codex.js` +- `lib/providers/ollama.js` + +Expected outcome: + +- Substantially improved backend regression detection on API and provider lifecycle paths. + +### Milestone 2: Frontend Core Unit Coverage + +Primary targets: + +- `public/js/conversations.js` +- `public/js/ui.js` +- `public/js/render.js` +- `public/js/websocket.js` +- `public/js/app.js` (targeted initialization paths) + +Expected outcome: + +- Core chat/file-panel UI behavior covered by deterministic Node-based unit tests. + +## Coverage Commands + +```bash +npm run test:coverage +npm run test:coverage -- --top=20 +npm run test:coverage -- --line-min=45 --branch-min=65 --func-min=50 +``` + +`test:coverage` exits non-zero if tests fail. Threshold flags are optional and can be used for CI gating. diff --git a/package.json b/package.json index 2086d0e..8df9877 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "start": "node server.js", "test": "node --test 'test/*.test.js'", + "test:coverage": "node scripts/coverage-summary.js", + "test:coverage:raw": "node --test --experimental-test-coverage 'test/*.test.js'", "lint": "eslint ." }, "devDependencies": { diff --git a/scripts/coverage-summary.js b/scripts/coverage-summary.js new file mode 100644 index 0000000..c6acc00 --- /dev/null +++ b/scripts/coverage-summary.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function parseArgs(argv) { + const options = { + lineMin: null, + branchMin: null, + funcMin: null, + top: 12, + verbose: false, + }; + + for (const arg of argv) { + if (arg.startsWith('--line-min=')) { + options.lineMin = Number(arg.slice('--line-min='.length)); + } else if (arg.startsWith('--branch-min=')) { + options.branchMin = Number(arg.slice('--branch-min='.length)); + } else if (arg.startsWith('--func-min=')) { + options.funcMin = Number(arg.slice('--func-min='.length)); + } else if (arg.startsWith('--top=')) { + const value = Number(arg.slice('--top='.length)); + if (Number.isFinite(value) && value > 0) { + options.top = Math.floor(value); + } + } else if (arg === '--verbose') { + options.verbose = true; + } + } + + return options; +} + +function getTestFiles() { + const testDir = path.join(process.cwd(), 'test'); + const files = fs.readdirSync(testDir) + .filter((name) => name.endsWith('.test.js')) + .sort() + .map((name) => path.join('test', name)); + return files; +} + +function parseCoverageRows(reportText) { + const rows = []; + const lines = String(reportText || '').split('\n'); + const rowPattern = /^ℹ\s+(.+?)\s+\|\s+([0-9.]+)\s+\|\s+([0-9.]+)\s+\|\s+([0-9.]+)/; + + for (const raw of lines) { + const match = raw.match(rowPattern); + if (!match) continue; + const name = match[1].trim(); + const linePct = Number(match[2]); + const branchPct = Number(match[3]); + const funcPct = Number(match[4]); + rows.push({ name, linePct, branchPct, funcPct }); + } + + const overall = rows.find((row) => row.name === 'all files') || null; + const fileRows = rows.filter((row) => row.name.includes('.js')); + return { overall, fileRows }; +} + +function formatPct(value) { + if (!Number.isFinite(value)) return 'n/a'; + return `${value.toFixed(2)}%`; +} + +function checkThreshold(label, actual, min) { + if (!Number.isFinite(min)) return { ok: true }; + if (Number.isFinite(actual) && actual >= min) return { ok: true }; + return { + ok: false, + message: `[COVERAGE] ${label} ${formatPct(actual)} is below required ${formatPct(min)}`, + }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const testFiles = getTestFiles(); + if (testFiles.length === 0) { + console.error('[COVERAGE] No test files found under test/*.test.js'); + process.exit(1); + } + + const args = ['--test', '--experimental-test-coverage', ...testFiles]; + const result = spawnSync(process.execPath, args, { + cwd: process.cwd(), + encoding: 'utf8', + env: process.env, + }); + + if (options.verbose || result.status !== 0) { + process.stdout.write(result.stdout || ''); + process.stderr.write(result.stderr || ''); + } + + const reportText = `${result.stdout || ''}\n${result.stderr || ''}`; + const { overall, fileRows } = parseCoverageRows(reportText); + + if (!overall) { + const code = Number.isInteger(result.status) ? result.status : 1; + process.exit(code); + } + + const sortedByLine = [...fileRows] + .sort((a, b) => a.linePct - b.linePct) + .slice(0, options.top); + + console.log('\n[COVERAGE] Summary'); + console.log(`- Line: ${formatPct(overall.linePct)}`); + console.log(`- Branch: ${formatPct(overall.branchPct)}`); + console.log(`- Functions: ${formatPct(overall.funcPct)}`); + + if (sortedByLine.length > 0) { + console.log(`[COVERAGE] Lowest ${sortedByLine.length} files by line coverage`); + for (const row of sortedByLine) { + console.log(`- ${row.name}: ${formatPct(row.linePct)} (branch ${formatPct(row.branchPct)}, funcs ${formatPct(row.funcPct)})`); + } + } + + let exitCode = Number.isInteger(result.status) ? result.status : 1; + if (exitCode === 0) { + const checks = [ + checkThreshold('line coverage', overall.linePct, options.lineMin), + checkThreshold('branch coverage', overall.branchPct, options.branchMin), + checkThreshold('function coverage', overall.funcPct, options.funcMin), + ]; + const failed = checks.filter((item) => !item.ok); + if (failed.length > 0) { + for (const item of failed) { + console.error(item.message); + } + exitCode = 1; + } + } + + process.exit(exitCode); +} + +main(); diff --git a/test/conversations-routes.test.js b/test/conversations-routes.test.js new file mode 100644 index 0000000..1130036 --- /dev/null +++ b/test/conversations-routes.test.js @@ -0,0 +1,480 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); + +const { requireWithMocks } = require('./helpers/require-with-mocks.cjs'); + +async function startServer(app) { + return new Promise((resolve) => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function stopServer(server) { + if (!server) return; + await new Promise((resolve) => server.close(resolve)); +} + +async function requestJson(baseUrl, method, routePath, body) { + const response = await fetch(`${baseUrl}${routePath}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body == null ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { + status: response.status, + body: text ? JSON.parse(text) : null, + }; +} + +function createConversationRouteFixture() { + const now = Date.now(); + const conversations = new Map([ + ['conv-1', { + id: 'conv-1', + name: 'Existing', + cwd: '/tmp/project', + provider: 'claude', + model: 'claude-sonnet-4.5', + executionMode: 'patch', + autopilot: false, + sandboxed: true, + claudeSessionId: 'claude-session-root', + codexSessionId: null, + messages: [ + { role: 'user', text: 'hello', timestamp: now - 3000 }, + { role: 'assistant', text: 'Needle answer', timestamp: now - 2000, sessionId: 'sess-tip' }, + ], + status: 'idle', + createdAt: now - 4000, + archived: false, + pinned: false, + }], + ]); + + let saveIndexCalls = 0; + const saveConversationCalls = []; + const deleteConversationFilesCalls = []; + const stopPreviewCalls = []; + const deleteEmbeddingCalls = []; + const providerCancelCalls = []; + let forceProviderListFailure = false; + let semanticResults = []; + let semanticError = null; + let embeddingsCount = 0; + let statsCache = null; + let generatedSummary = 'summary'; + + const dataModule = { + conversations, + convMeta(conv) { + return { + id: conv.id, + name: conv.name, + cwd: conv.cwd, + status: conv.status, + archived: !!conv.archived, + pinned: !!conv.pinned, + provider: conv.provider || 'claude', + model: conv.model || 'claude-sonnet-4.5', + executionMode: conv.executionMode || 'patch', + autopilot: !!conv.autopilot, + messageCount: Array.isArray(conv.messages) ? conv.messages.length : 0, + lastMessage: Array.isArray(conv.messages) && conv.messages.length > 0 ? conv.messages.at(-1) : null, + createdAt: conv.createdAt, + }; + }, + async saveIndex() { + saveIndexCalls += 1; + }, + async saveConversation(id) { + saveConversationCalls.push(id); + }, + async loadMessages(id) { + return conversations.get(id)?.messages || []; + }, + async deleteConversationFiles(id) { + deleteConversationFilesCalls.push(id); + }, + async ensureMessages(id) { + return conversations.get(id) || null; + }, + getStatsCache() { + return statsCache; + }, + setStatsCache(value) { + statsCache = value; + }, + }; + + const providerModule = { + getAllProviders() { + if (forceProviderListFailure) { + throw new Error('providers unavailable'); + } + return [ + { id: 'claude', name: 'Claude' }, + { id: 'codex', name: 'OpenAI Codex' }, + ]; + }, + getProvider(id) { + if (id === 'claude') { + return { + getModels: async () => [{ id: 'claude-sonnet-4.5', name: 'Sonnet', context: 200000 }], + cancel: (conversationId) => providerCancelCalls.push({ provider: id, conversationId }), + generateSummary: async () => generatedSummary, + }; + } + if (id === 'codex') { + return { + getModels: async () => [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', context: 128000 }], + cancel: (conversationId) => providerCancelCalls.push({ provider: id, conversationId }), + generateSummary: async () => generatedSummary, + }; + } + throw new Error(`unknown provider ${id}`); + }, + }; + + const executionModeModule = { + EXECUTION_MODE_VALUES: new Set(['patch', 'autonomous']), + normalizeExecutionMode(mode) { + return mode === 'autonomous' ? 'autonomous' : 'patch'; + }, + inferExecutionModeFromLegacyAutopilot(flag) { + return flag ? 'autonomous' : 'patch'; + }, + resolveConversationExecutionMode(conv) { + return conv.executionMode || 'patch'; + }, + modeToLegacyAutopilot(mode) { + return mode === 'autonomous'; + }, + applyExecutionMode(conv, mode) { + conv.executionMode = mode; + conv.autopilot = mode === 'autonomous'; + }, + }; + + const routeModule = requireWithMocks('../lib/routes/conversations', { + [require.resolve('../lib/data')]: dataModule, + [require.resolve('../lib/claude')]: { + generateSummary: async () => generatedSummary, + MODELS: [{ id: 'claude-sonnet-4.5', name: 'Sonnet', context: 200000 }], + }, + [require.resolve('../lib/providers/codex')]: { + MODELS: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', context: 128000 }], + }, + [require.resolve('../lib/providers')]: providerModule, + [require.resolve('./../lib/routes/helpers')]: { + withConversation(handler) { + return async (req, res) => { + const conv = conversations.get(req.params.id); + if (!conv) return res.status(404).json({ error: 'Not found' }); + return handler(req, res, conv); + }; + }, + }, + [require.resolve('../lib/routes/preview')]: { + stopPreview(id) { + stopPreviewCalls.push(id); + }, + }, + [require.resolve('../lib/embeddings')]: { + async semanticSearch() { + if (semanticError) throw semanticError; + return semanticResults; + }, + deleteEmbedding(id) { + deleteEmbeddingCalls.push(id); + }, + getEmbeddingsCount() { + return embeddingsCount; + }, + }, + [require.resolve('../lib/workflow/execution-mode')]: executionModeModule, + }, __filename); + + return { + setupConversationRoutes: routeModule.setupConversationRoutes, + state: { + conversations, + setProviderListFailure(value) { + forceProviderListFailure = !!value; + }, + setSemanticResults(value) { + semanticResults = Array.isArray(value) ? value : []; + }, + setSemanticError(err) { + semanticError = err || null; + }, + setEmbeddingsCount(value) { + embeddingsCount = Number(value) || 0; + }, + setGeneratedSummary(value) { + generatedSummary = String(value || ''); + }, + getSaveIndexCalls: () => saveIndexCalls, + getSaveConversationCalls: () => [...saveConversationCalls], + getDeleteConversationFilesCalls: () => [...deleteConversationFilesCalls], + getStopPreviewCalls: () => [...stopPreviewCalls], + getDeleteEmbeddingCalls: () => [...deleteEmbeddingCalls], + getProviderCancelCalls: () => [...providerCancelCalls], + }, + }; +} + +describe('conversation routes', () => { + let server; + let baseUrl; + let state; + + beforeEach(async () => { + const fixture = createConversationRouteFixture(); + state = fixture.state; + const app = express(); + app.use(express.json()); + fixture.setupConversationRoutes(app); + server = await startServer(app); + baseUrl = `http://127.0.0.1:${server.address().port}`; + }); + + afterEach(async () => { + await stopServer(server); + server = null; + baseUrl = null; + state = null; + }); + + it('falls back to static providers when provider registry is unavailable', async () => { + state.setProviderListFailure(true); + const response = await requestJson(baseUrl, 'GET', '/api/providers'); + assert.equal(response.status, 200); + assert.deepEqual( + response.body.map((item) => item.id), + ['claude', 'codex', 'ollama'] + ); + }); + + it('returns 400 for unknown provider model requests', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/models?provider=unknown'); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'Unknown provider: unknown'); + }); + + it('returns models for known providers', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/models?provider=codex'); + assert.equal(response.status, 200); + assert.equal(response.body.length, 1); + assert.equal(response.body[0].id, 'gpt-5.3-codex'); + }); + + it('validates provider on conversation create', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/conversations', { + name: 'Bad provider', + provider: 'not-a-provider', + }); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'Invalid provider. Valid providers: claude, codex, ollama'); + }); + + it('creates a conversation with provider default model and persists it', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/conversations', { + name: 'Codex chat', + provider: 'codex', + }); + assert.equal(response.status, 200); + assert.equal(response.body.provider, 'codex'); + assert.equal(response.body.model, 'gpt-5.3-codex'); + assert.equal(response.body.executionMode, 'patch'); + assert.equal(state.getSaveConversationCalls().length, 1); + }); + + it('rejects invalid provider updates', async () => { + const response = await requestJson(baseUrl, 'PATCH', '/api/conversations/conv-1', { + provider: 'invalid-provider', + }); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'Invalid provider. Valid providers: claude, codex, ollama'); + }); + + it('lists conversations and sorts pinned before recency', async () => { + state.conversations.set('conv-2', { + id: 'conv-2', + name: 'Pinned conversation', + cwd: '/tmp/project', + provider: 'claude', + model: 'claude-sonnet-4.5', + executionMode: 'patch', + messages: [{ role: 'assistant', text: 'fresh', timestamp: Date.now() }], + status: 'idle', + createdAt: Date.now() - 1000, + archived: false, + pinned: true, + }); + + const response = await requestJson(baseUrl, 'GET', '/api/conversations'); + assert.equal(response.status, 200); + assert.equal(response.body.length >= 2, true); + assert.equal(response.body[0].id, 'conv-2'); + }); + + it('searches by text and model filters', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/conversations/search?q=needle&model=claude-sonnet-4.5'); + assert.equal(response.status, 200); + assert.equal(response.body.length, 1); + assert.equal(response.body[0].id, 'conv-1'); + assert.equal(response.body[0].matchingMessages.length, 1); + }); + + it('enriches semantic search results with conversation metadata', async () => { + state.setSemanticResults([{ id: 'conv-1', score: 0.92, text: 'semantic match text sample' }]); + const response = await requestJson(baseUrl, 'GET', '/api/conversations/semantic-search?q=match'); + assert.equal(response.status, 200); + assert.equal(response.body.length, 1); + assert.equal(response.body[0].id, 'conv-1'); + assert.equal(response.body[0].score, 0.92); + assert.equal(typeof response.body[0].matchText, 'string'); + }); + + it('returns 500 when semantic search throws', async () => { + state.setSemanticError(new Error('embedding service unavailable')); + const response = await requestJson(baseUrl, 'GET', '/api/conversations/semantic-search?q=match'); + assert.equal(response.status, 500); + assert.equal(response.body.error, 'Semantic search failed'); + }); + + it('returns embedding stats count', async () => { + state.setEmbeddingsCount(17); + const response = await requestJson(baseUrl, 'GET', '/api/embeddings/stats'); + assert.equal(response.status, 200); + assert.equal(response.body.count, 17); + }); + + it('updates provider and execution mode and resets provider sessions', async () => { + const conv = state.conversations.get('conv-1'); + conv.claudeSessionId = 'existing-claude-session'; + conv.codexSessionId = 'existing-codex-session'; + + const response = await requestJson(baseUrl, 'PATCH', '/api/conversations/conv-1', { + provider: 'codex', + executionMode: 'autonomous', + model: 'gpt-5.3-codex', + pinned: true, + useMemory: true, + }); + + assert.equal(response.status, 200); + assert.equal(response.body.provider, 'codex'); + assert.equal(response.body.executionMode, 'autonomous'); + assert.equal(response.body.autopilot, true); + assert.equal(conv.claudeSessionId, null); + assert.equal(conv.codexSessionId, null); + assert.equal(state.getSaveIndexCalls() >= 1, true); + }); + + it('returns conversation details and 404 for missing conversation', async () => { + const found = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1'); + assert.equal(found.status, 200); + assert.equal(found.body.id, 'conv-1'); + + const missing = await requestJson(baseUrl, 'GET', '/api/conversations/does-not-exist'); + assert.equal(missing.status, 404); + assert.equal(missing.body.error, 'Not found'); + }); + + it('computes and caches stats', async () => { + const first = await requestJson(baseUrl, 'GET', '/api/stats'); + assert.equal(first.status, 200); + assert.equal(first.body.conversations.total >= 1, true); + assert.equal(first.body.messages.total >= 1, true); + + const second = await requestJson(baseUrl, 'GET', '/api/stats'); + assert.equal(second.status, 200); + assert.deepEqual(second.body, first.body); + }); + + it('exports conversation as JSON and markdown', async () => { + const jsonResponse = await fetch(`${baseUrl}/api/conversations/conv-1/export?format=json`); + assert.equal(jsonResponse.status, 200); + const jsonBody = await jsonResponse.json(); + assert.equal(jsonBody.name, 'Existing'); + assert.equal(Array.isArray(jsonBody.messages), true); + + const mdResponse = await fetch(`${baseUrl}/api/conversations/conv-1/export`); + assert.equal(mdResponse.status, 200); + const markdown = await mdResponse.text(); + assert.ok(markdown.includes('# Existing')); + assert.ok(markdown.includes('Assistant')); + }); + + it('validates fork input and creates a same-workspace fork with inherited session context', async () => { + const invalid = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/fork', {}); + assert.equal(invalid.status, 400); + assert.equal(invalid.body.error, 'fromMessageIndex required'); + + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/fork', { + fromMessageIndex: 1, + }); + assert.equal(response.status, 200); + assert.equal(response.body.parentId, 'conv-1'); + assert.equal(response.body.forkIndex, 1); + assert.equal(response.body.workspaceMode, 'same'); + assert.equal(response.body.claudeForkSessionId, 'sess-tip'); + assert.equal(state.getSaveConversationCalls().length >= 1, true); + }); + + it('compresses a conversation and marks old messages summarized', async () => { + const conv = state.conversations.get('conv-1'); + conv.messages.push({ role: 'user', text: 'third message', timestamp: Date.now() - 1000 }); + conv.claudeSessionId = 'session-before-compress'; + conv.codexSessionId = 'codex-before-compress'; + state.setGeneratedSummary('Compressed summary'); + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/compress', { + threshold: 1, + }); + assert.equal(response.status, 200); + assert.equal(response.body.success, true); + assert.equal(response.body.messagesSummarized, 3); + + assert.equal(conv.claudeSessionId, null); + assert.equal(conv.codexSessionId, null); + assert.equal(conv.messages.some((m) => m.role === 'system' && m.text === 'Compressed summary'), true); + }); + + it('builds a conversation tree with child metadata', async () => { + state.conversations.set('child-1', { + id: 'child-1', + name: 'Forked', + cwd: '/tmp/project', + provider: 'claude', + model: 'claude-sonnet-4.5', + executionMode: 'patch', + messages: [{ role: 'assistant', text: 'child' }], + status: 'idle', + createdAt: Date.now(), + parentId: 'conv-1', + forkIndex: 1, + }); + + const response = await requestJson(baseUrl, 'GET', '/api/conversations/child-1/tree'); + assert.equal(response.status, 200); + assert.equal(response.body.rootId, 'conv-1'); + assert.equal(response.body.tree.id, 'conv-1'); + assert.equal(response.body.tree.children.length, 1); + assert.equal(response.body.tree.children[0].id, 'child-1'); + }); + + it('deletes conversation and triggers cleanup side effects', async () => { + const response = await requestJson(baseUrl, 'DELETE', '/api/conversations/conv-1'); + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); + assert.equal(state.conversations.has('conv-1'), false); + assert.deepEqual(state.getProviderCancelCalls(), [{ provider: 'claude', conversationId: 'conv-1' }]); + assert.deepEqual(state.getStopPreviewCalls(), ['conv-1']); + assert.deepEqual(state.getDeleteEmbeddingCalls(), ['conv-1']); + assert.deepEqual(state.getDeleteConversationFilesCalls(), ['conv-1']); + assert.equal(state.getSaveIndexCalls(), 1); + }); +}); diff --git a/test/files-routes.test.js b/test/files-routes.test.js new file mode 100644 index 0000000..2230f9d --- /dev/null +++ b/test/files-routes.test.js @@ -0,0 +1,255 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); +const path = require('path'); +const os = require('os'); +const fs = require('fs').promises; + +const { requireWithMocks } = require('./helpers/require-with-mocks.cjs'); + +async function startServer(app) { + return new Promise((resolve) => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function stopServer(server) { + if (!server) return; + await new Promise((resolve) => server.close(resolve)); +} + +async function requestJson(baseUrl, method, routePath, body) { + const response = await fetch(`${baseUrl}${routePath}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body == null ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { + status: response.status, + body: text ? JSON.parse(text) : null, + }; +} + +function isInside(baseCwd, targetPath) { + const rel = path.relative(path.resolve(baseCwd), path.resolve(targetPath)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function createFileRouteFixture() { + const conversations = new Map([ + ['conv-1', { id: 'conv-1', cwd: '/workspace/project' }], + ]); + + let directoryError = null; + let isRepo = true; + let gitResult = { ok: true, stdout: '', stderr: '' }; + const downloads = []; + + const helpers = { + withConversation(handler) { + return async (req, res) => { + const conv = conversations.get(req.params.id); + if (!conv) return res.status(404).json({ error: 'Not found' }); + return handler(req, res, conv); + }; + }, + sanitizeFilename(name) { + return String(name || '').replace(/[^a-zA-Z0-9._-]/g, '_'); + }, + handleFileUpload(_req, res, filePath, formatter) { + const response = formatter ? formatter(filePath, path.basename(filePath)) : { path: filePath }; + res.json(response); + }, + isPathWithinCwd(baseCwd, targetPath) { + return isInside(baseCwd, targetPath); + }, + async listDirectory(_resolved, _opts) { + if (directoryError) return directoryError; + return { + entries: [ + { name: 'a.txt', isDirectory: false }, + { name: 'subdir', isDirectory: true }, + ], + }; + }, + async isGitRepo() { + return isRepo; + }, + async runGit(_cwd, _args) { + return gitResult; + }, + async sendFileDownload(res, filePath, { inline }) { + downloads.push({ filePath, inline }); + res.json({ ok: true, filePath, inline }); + }, + }; + + const routeModule = requireWithMocks('../lib/routes/files', { + [require.resolve('../lib/data')]: { + UPLOAD_DIR: '/tmp/uploads', + }, + [require.resolve('../lib/routes/helpers')]: helpers, + }, __filename); + + return { + setupFileRoutes: routeModule.setupFileRoutes, + state: { + setDirectoryError(err) { + directoryError = err; + }, + setIsRepo(value) { + isRepo = !!value; + }, + setGitResult(value) { + gitResult = value; + }, + getDownloads() { + return [...downloads]; + }, + }, + }; +} + +describe('file routes', () => { + let server; + let baseUrl; + let state; + let tmpRoot; + + beforeEach(async () => { + const fixture = createFileRouteFixture(); + state = fixture.state; + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'concierge-files-routes-')); + const app = express(); + app.use(express.json()); + fixture.setupFileRoutes(app); + server = await startServer(app); + baseUrl = `http://127.0.0.1:${server.address().port}`; + }); + + afterEach(async () => { + await stopServer(server); + if (tmpRoot) { + await fs.rm(tmpRoot, { recursive: true, force: true }); + } + server = null; + baseUrl = null; + state = null; + tmpRoot = null; + }); + + it('returns listDirectory errors for /api/files', async () => { + state.setDirectoryError({ status: 403, error: 'Access denied', code: 'ACCESS_DENIED' }); + const response = await requestJson(baseUrl, 'GET', '/api/files?path=/restricted'); + assert.equal(response.status, 403); + assert.equal(response.body.error, 'Access denied'); + assert.equal(response.body.code, 'ACCESS_DENIED'); + }); + + it('returns directory entries for /api/files', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/files?path=/workspace/project'); + assert.equal(response.status, 200); + assert.equal(response.body.path, '/workspace/project'); + assert.equal(response.body.entries.length, 2); + }); + + it('denies traversal outside cwd for conversation file list', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1/files?path=../secrets'); + assert.equal(response.status, 403); + assert.equal(response.body.error, 'Access denied'); + }); + + it('validates required q parameter on conversation file search', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1/files/search'); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'q parameter required'); + }); + + it('requires git repository for conversation file search', async () => { + state.setIsRepo(false); + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1/files/search?q=hello'); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'Search requires a git repository'); + }); + + it('parses git grep output into structured search results', async () => { + state.setGitResult({ + ok: true, + stdout: 'src/a.js:10:const value = 1\nsrc/b.js:3:hello world\n', + stderr: '', + }); + + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1/files/search?q=hello'); + assert.equal(response.status, 200); + assert.equal(response.body.results.length, 2); + assert.deepEqual(response.body.results[0], { path: 'src/a.js', line: 10, content: 'const value = 1' }); + }); + + it('validates required path for general download endpoint', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/files/download'); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'path required'); + }); + + it('validates required path for file content endpoints', async () => { + const general = await requestJson(baseUrl, 'GET', '/api/files/content'); + assert.equal(general.status, 400); + assert.equal(general.body.error, 'path required'); + + const convo = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1/files/content'); + assert.equal(convo.status, 400); + assert.equal(convo.body.error, 'path required'); + }); + + it('blocks traversal for conversation file content endpoint', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1/files/content?path=../secret.txt'); + assert.equal(response.status, 403); + assert.equal(response.body.error, 'Access denied'); + }); + + it('validates upload filename for generic upload route', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/files/upload?path=/workspace/project'); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'filename required'); + }); + + it('returns conversation upload URL and sanitizes filename', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/upload?filename=../../unsafe name.txt'); + assert.equal(response.status, 200); + assert.ok(response.body.filename.includes('_')); + assert.ok(response.body.url.startsWith('/uploads/conv-1/')); + }); + + it('returns browse error for invalid directory path', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/browse?path=/definitely/not/a/real/path'); + assert.equal(response.status, 400); + assert.equal(typeof response.body.error, 'string'); + }); + + it('creates a directory through mkdir endpoint', async () => { + const target = path.join(tmpRoot, 'nested', 'folder'); + const response = await requestJson(baseUrl, 'POST', '/api/mkdir', { path: target }); + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); + const stat = await fs.stat(target); + assert.equal(stat.isDirectory(), true); + }); + + it('validates and handles delete endpoint errors', async () => { + const missingParam = await requestJson(baseUrl, 'DELETE', '/api/files'); + assert.equal(missingParam.status, 400); + assert.equal(missingParam.body.error, 'path required'); + + const missingFile = await requestJson(baseUrl, 'DELETE', `/api/files?path=${encodeURIComponent(path.join(tmpRoot, 'missing.txt'))}`); + assert.equal(missingFile.status, 404); + assert.equal(missingFile.body.error, 'File not found'); + }); + + it('rejects conversation file download outside cwd', async () => { + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-1/files/download?path=../../passwd'); + assert.equal(response.status, 403); + assert.equal(response.body.error, 'Access denied'); + assert.equal(state.getDownloads().length, 0); + }); +}); diff --git a/test/git-routes.test.js b/test/git-routes.test.js new file mode 100644 index 0000000..6f12cfa --- /dev/null +++ b/test/git-routes.test.js @@ -0,0 +1,408 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); +const path = require('path'); + +const { requireWithMocks } = require('./helpers/require-with-mocks.cjs'); + +async function startServer(app) { + return new Promise((resolve) => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function stopServer(server) { + if (!server) return; + await new Promise((resolve) => server.close(resolve)); +} + +async function requestJson(baseUrl, method, routePath, body) { + const response = await fetch(`${baseUrl}${routePath}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body == null ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { + status: response.status, + body: text ? JSON.parse(text) : null, + }; +} + +function isInside(baseCwd, targetPath) { + const rel = path.relative(path.resolve(baseCwd), path.resolve(targetPath)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function createGitRouteFixture() { + const conversations = new Map([ + ['conv-auto', { id: 'conv-auto', name: 'Auto', cwd: '/repo', executionMode: 'autonomous' }], + ['conv-read', { id: 'conv-read', name: 'Read only', cwd: '/repo', executionMode: 'patch' }], + ['conv-blocker', { id: 'conv-blocker', name: 'Blocker', cwd: '/repo', executionMode: 'autonomous' }], + ]); + + let gitRepo = true; + let lock = null; + const gitCalls = []; + const gitResults = new Map(); + + function setGitResult(args, value) { + gitResults.set(JSON.stringify(args), value); + } + + async function runGit(_cwd, args) { + gitCalls.push(args); + const key = JSON.stringify(args); + const configured = gitResults.get(key); + if (Array.isArray(configured)) { + if (configured.length === 0) return { ok: true, stdout: '', stderr: '' }; + return configured.shift(); + } + if (typeof configured === 'function') { + return configured(args, gitCalls); + } + if (configured) return configured; + return { ok: true, stdout: '', stderr: '' }; + } + + const helpers = { + withConversation(handler) { + return async (req, res) => { + const conv = conversations.get(req.params.id); + if (!conv) return res.status(404).json({ error: 'Not found' }); + return handler(req, res, conv); + }; + }, + withGitRepo(handler) { + return async (req, res) => { + const conv = conversations.get(req.params.id); + if (!conv) return res.status(404).json({ error: 'Not found' }); + if (!gitRepo) return res.status(400).json({ error: 'Not a git repository' }); + return handler(req, res, conv, conv.cwd || '/repo'); + }; + }, + withCwd(handler) { + return async (req, res) => { + return handler(req, res, '/repo'); + }; + }, + isPathWithinCwd(baseCwd, targetPath) { + return isInside(baseCwd, targetPath); + }, + validatePathsWithinCwd(cwd, paths) { + for (const entry of paths || []) { + if (!isInside(cwd, path.resolve(cwd, entry))) { + return { valid: false, invalidPath: entry }; + } + } + return { valid: true }; + }, + runGit, + async isGitRepo() { + return gitRepo; + }, + }; + + const routeModule = requireWithMocks('../lib/routes/git', { + [require.resolve('../lib/routes/helpers')]: helpers, + [require.resolve('../lib/data')]: { conversations }, + [require.resolve('../lib/workflow/execution-mode')]: { + resolveConversationExecutionMode(conv) { + return conv.executionMode || 'patch'; + }, + }, + [require.resolve('../lib/workflow/locks')]: { + canWrite(_cwd, conversationId) { + if (!lock) return true; + return lock.writerConversationId === conversationId; + }, + getLock() { + return lock; + }, + }, + }, __filename); + + return { + setupGitRoutes: routeModule.setupGitRoutes, + state: { + setGitRepo(value) { + gitRepo = !!value; + }, + setLock(value) { + lock = value; + }, + clearGitCalls() { + gitCalls.length = 0; + }, + setGitResult, + getGitCalls() { + return [...gitCalls]; + }, + }, + }; +} + +describe('git routes', () => { + let server; + let baseUrl; + let state; + + beforeEach(async () => { + const fixture = createGitRouteFixture(); + state = fixture.state; + const app = express(); + app.use(express.json()); + fixture.setupGitRoutes(app); + server = await startServer(app); + baseUrl = `http://127.0.0.1:${server.address().port}`; + }); + + afterEach(async () => { + await stopServer(server); + server = null; + baseUrl = null; + state = null; + }); + + it('blocks write routes when conversation execution mode is read-only', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-read/git/stage', { + paths: ['a.txt'], + }); + assert.equal(response.status, 403); + assert.equal(response.body.code, 'EXECUTION_MODE_READONLY'); + assert.equal(response.body.executionMode, 'patch'); + }); + + it('returns lock metadata when another conversation owns the write lock', async () => { + state.setLock({ cwd: '/repo', writerConversationId: 'conv-blocker' }); + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-auto/git/stage', { + paths: ['a.txt'], + }); + assert.equal(response.status, 409); + assert.equal(response.body.code, 'WRITE_LOCKED'); + assert.equal(response.body.blockerConversationId, 'conv-blocker'); + assert.equal(response.body.blockerConversationName, 'Blocker'); + }); + + it('returns isRepo false on status when cwd is not a git repository', async () => { + state.setGitRepo(false); + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-auto/git/status'); + assert.equal(response.status, 200); + assert.equal(response.body.isRepo, false); + }); + + it('parses status output into staged, unstaged, and untracked buckets', async () => { + state.setGitResult(['rev-parse', '--abbrev-ref', 'HEAD'], { ok: true, stdout: 'feature-x\n', stderr: '' }); + state.setGitResult(['remote', 'get-url', 'origin'], { ok: true, stdout: 'git@github.com:org/repo.git\n', stderr: '' }); + state.setGitResult(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], { ok: true, stdout: '2 1\n', stderr: '' }); + state.setGitResult(['status', '--porcelain=v1'], { + ok: true, + stdout: 'M src/a.js\n M src/b.js\n?? new.txt\n', + stderr: '', + }); + + const response = await requestJson(baseUrl, 'GET', '/api/conversations/conv-auto/git/status'); + assert.equal(response.status, 200); + assert.equal(response.body.isRepo, true); + assert.equal(response.body.branch, 'feature-x'); + assert.equal(response.body.ahead, 2); + assert.equal(response.body.behind, 1); + assert.equal(response.body.hasOrigin, true); + assert.equal(response.body.hasUpstream, true); + assert.deepEqual(response.body.staged, [{ path: 'src/a.js', status: 'M' }]); + assert.deepEqual(response.body.unstaged, [{ path: 'src/b.js', status: 'M' }]); + assert.deepEqual(response.body.untracked, [{ path: 'new.txt' }]); + }); + + it('parses local and remote branches', async () => { + state.setGitResult(['rev-parse', '--abbrev-ref', 'HEAD'], { ok: true, stdout: 'main\n', stderr: '' }); + state.setGitResult(['branch', '-a'], { + ok: true, + stdout: '* main\n feature/data\n remotes/origin/main\n remotes/origin/feature/data\n', + stderr: '', + }); + + const response = await requestJson(baseUrl, 'GET', '/api/git/branches'); + assert.equal(response.status, 200); + assert.equal(response.body.current, 'main'); + assert.deepEqual(response.body.local, ['main', 'feature/data']); + assert.deepEqual(response.body.remote, ['origin/main', 'origin/feature/data']); + }); + + it('validates diff path and blocks traversal', async () => { + const missingPath = await requestJson(baseUrl, 'POST', '/api/git/diff', {}); + assert.equal(missingPath.status, 400); + assert.equal(missingPath.body.error, 'path required'); + + const denied = await requestJson(baseUrl, 'POST', '/api/git/diff', { path: '../secret.txt' }); + assert.equal(denied.status, 403); + assert.equal(denied.body.error, 'Access denied'); + }); + + it('returns parsed hunks for diff requests', async () => { + state.setGitResult(['diff', '--', 'src/a.js'], { + ok: true, + stdout: 'diff --git a/src/a.js b/src/a.js\n--- a/src/a.js\n+++ b/src/a.js\n@@ -1 +1 @@\n-old\n+new\n', + stderr: '', + }); + + const response = await requestJson(baseUrl, 'POST', '/api/git/diff', { path: 'src/a.js' }); + assert.equal(response.status, 200); + assert.equal(response.body.path, 'src/a.js'); + assert.equal(response.body.hunks.length, 1); + assert.equal(response.body.hunks[0].header, '@@ -1 +1 @@'); + }); + + it('validates stage request paths', async () => { + const missingPaths = await requestJson(baseUrl, 'POST', '/api/git/stage', {}); + assert.equal(missingPaths.status, 400); + assert.equal(missingPaths.body.error, 'paths required'); + + const denied = await requestJson(baseUrl, 'POST', '/api/git/stage', { paths: ['../x'] }); + assert.equal(denied.status, 403); + assert.equal(denied.body.error, 'Access denied'); + }); + + it('returns commit hash on successful commit', async () => { + state.setGitResult(['commit', '-m', 'ship it'], { ok: true, stdout: '[main abc1234] ship it\n', stderr: '' }); + state.setGitResult(['rev-parse', '--short', 'HEAD'], { ok: true, stdout: 'abc1234\n', stderr: '' }); + + const response = await requestJson(baseUrl, 'POST', '/api/git/commit', { message: 'ship it' }); + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); + assert.equal(response.body.hash, 'abc1234'); + }); + + it('validates branch name before creating branch', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/git/branch', { name: 'bad branch name' }); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'Invalid branch name'); + }); + + it('maps push authentication failures to 401', async () => { + state.setGitResult(['rev-parse', '--abbrev-ref', 'HEAD'], { ok: true, stdout: 'feature\n', stderr: '' }); + state.setGitResult(['rev-parse', '--abbrev-ref', '@{upstream}'], { ok: true, stdout: 'origin/feature\n', stderr: '' }); + state.setGitResult(['push'], { ok: false, stdout: '', stderr: 'fatal: Authentication failed' }); + + const response = await requestJson(baseUrl, 'POST', '/api/git/push', {}); + assert.equal(response.status, 401); + assert.equal(response.body.error, 'Authentication failed. Check your credentials.'); + }); + + it('uses upstream bootstrap args when branch has no upstream on push', async () => { + state.setGitResult(['rev-parse', '--abbrev-ref', 'HEAD'], { ok: true, stdout: 'feature\n', stderr: '' }); + state.setGitResult(['rev-parse', '--abbrev-ref', '@{upstream}'], { ok: false, stdout: '', stderr: 'no upstream' }); + state.setGitResult(['push', '-u', 'origin', 'feature'], { ok: true, stdout: 'ok', stderr: '' }); + + const response = await requestJson(baseUrl, 'POST', '/api/git/push', {}); + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); + const calls = state.getGitCalls().map((args) => JSON.stringify(args)); + assert.ok(calls.includes(JSON.stringify(['push', '-u', 'origin', 'feature']))); + }); + + it('maps pull local-changes failures to conflict', async () => { + state.setGitResult(['pull'], { ok: false, stdout: '', stderr: 'Your local changes would be overwritten by merge' }); + const response = await requestJson(baseUrl, 'POST', '/api/git/pull', {}); + assert.equal(response.status, 409); + assert.equal(response.body.error, 'Commit or stash changes before pulling.'); + }); + + it('parses stash list and handles no-op stash creation', async () => { + state.setGitResult(['stash', 'list', '--format=%gd|%s|%ar'], { + ok: true, + stdout: 'stash@{0}|WIP on main|2 hours ago\nstash@{1}|temp fixes|1 day ago\n', + stderr: '', + }); + const list = await requestJson(baseUrl, 'GET', '/api/git/stash'); + assert.equal(list.status, 200); + assert.equal(list.body.stashes.length, 2); + assert.equal(list.body.stashes[0].index, 0); + assert.equal(list.body.stashes[1].message, 'temp fixes'); + + state.setGitResult(['stash'], { ok: true, stdout: 'No local changes to save\n', stderr: '' }); + const create = await requestJson(baseUrl, 'POST', '/api/git/stash', {}); + assert.equal(create.status, 400); + assert.equal(create.body.error, 'No local changes to stash'); + }); + + it('validates stash index and maps stash conflicts', async () => { + const badIndex = await requestJson(baseUrl, 'POST', '/api/git/stash/pop', { index: -1 }); + assert.equal(badIndex.status, 400); + assert.equal(badIndex.body.error, 'Invalid stash index'); + + state.setGitResult(['stash', 'pop', 'stash@{0}'], { ok: false, stdout: '', stderr: 'merge conflict' }); + const conflict = await requestJson(baseUrl, 'POST', '/api/git/stash/pop', { index: 0 }); + assert.equal(conflict.status, 409); + assert.equal(conflict.body.error, 'Merge conflict while applying stash'); + }); + + it('handles revert conflict by aborting revert state', async () => { + state.clearGitCalls(); + state.setGitResult(['revert', 'abcdef1', '--no-edit'], { ok: false, stdout: '', stderr: 'conflict' }); + state.setGitResult(['revert', '--abort'], { ok: true, stdout: '', stderr: '' }); + + const response = await requestJson(baseUrl, 'POST', '/api/git/revert', { hash: 'abcdef1' }); + assert.equal(response.status, 409); + assert.equal(response.body.error, 'Conflict while reverting. Revert aborted.'); + + const calls = state.getGitCalls().map((args) => JSON.stringify(args)); + assert.ok(calls.includes(JSON.stringify(['revert', '--abort']))); + }); + + it('validates reset mode and undo-commit preconditions', async () => { + const badMode = await requestJson(baseUrl, 'POST', '/api/git/reset', { hash: 'abcdef1', mode: 'invalid' }); + assert.equal(badMode.status, 400); + assert.equal(badMode.body.error, 'Invalid reset mode. Must be soft, mixed, or hard.'); + + state.setGitResult(['rev-parse', 'HEAD'], { ok: false, stdout: '', stderr: 'no commits' }); + const undo = await requestJson(baseUrl, 'POST', '/api/git/undo-commit', {}); + assert.equal(undo.status, 400); + assert.equal(undo.body.error, 'No commits to undo'); + }); + + it('parses commit list and commit detail payloads', async () => { + state.setGitResult(['log', '--format=%H|%s|%an|%ar', '-n', '20'], { + ok: true, + stdout: 'abc1234|Fix bug|Alex|2 days ago\n', + stderr: '', + }); + const commits = await requestJson(baseUrl, 'GET', '/api/git/commits'); + assert.equal(commits.status, 200); + assert.equal(commits.body.commits.length, 1); + assert.equal(commits.body.commits[0].hash, 'abc1234'); + + state.setGitResult(['log', '--format=%s|%an|%ar', '-n', '1', 'abc1234'], { + ok: true, + stdout: 'Fix bug|Alex|2 days ago\n', + stderr: '', + }); + state.setGitResult(['show', '--format=', 'abc1234'], { + ok: true, + stdout: 'diff --git a/a.js b/a.js\n', + stderr: '', + }); + const detail = await requestJson(baseUrl, 'GET', '/api/git/commits/abc1234'); + assert.equal(detail.status, 200); + assert.equal(detail.body.hash, 'abc1234'); + assert.equal(detail.body.message, 'Fix bug'); + assert.equal(detail.body.author, 'Alex'); + assert.ok(detail.body.raw.includes('diff --git')); + }); + + it('validates hunk action before applying a patch', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/git/hunk-action', { + path: 'src/a.js', + staged: true, + action: 'stage', + hunk: { + header: '@@ -1 +1 @@', + lines: ['-old', '+new'], + }, + }); + + assert.equal(response.status, 400); + assert.equal(response.body.code, 'INVALID_HUNK_ACTION'); + assert.equal(response.body.error, 'Invalid hunk action for current diff state'); + }); +}); diff --git a/test/helpers/require-with-mocks.cjs b/test/helpers/require-with-mocks.cjs new file mode 100644 index 0000000..887bebb --- /dev/null +++ b/test/helpers/require-with-mocks.cjs @@ -0,0 +1,37 @@ +const Module = require('module'); +const path = require('path'); + +function requireWithMocks(targetModulePath, mocks = {}, fromPath = __filename) { + const callerRequire = Module.createRequire(path.resolve(fromPath)); + const targetResolved = callerRequire.resolve(targetModulePath); + const originalLoad = Module._load; + + Module._load = function patchedLoad(request, parent, isMain) { + let resolvedRequest = null; + try { + resolvedRequest = Module._resolveFilename(request, parent, isMain); + } catch { + // Fall back to raw request lookup below. + } + + if (resolvedRequest && Object.prototype.hasOwnProperty.call(mocks, resolvedRequest)) { + return mocks[resolvedRequest]; + } + if (Object.prototype.hasOwnProperty.call(mocks, request)) { + return mocks[request]; + } + return originalLoad(request, parent, isMain); + }; + + delete require.cache[targetResolved]; + try { + return require(targetResolved); + } finally { + Module._load = originalLoad; + delete require.cache[targetResolved]; + } +} + +module.exports = { + requireWithMocks, +}; diff --git a/test/routes-index.test.js b/test/routes-index.test.js new file mode 100644 index 0000000..6e785e7 --- /dev/null +++ b/test/routes-index.test.js @@ -0,0 +1,36 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { requireWithMocks } = require('./helpers/require-with-mocks.cjs'); + +describe('route index setup', () => { + it('registers all route modules with the same app instance', () => { + const calls = []; + const app = { name: 'test-app' }; + + function makeSetup(name) { + return (receivedApp) => { + calls.push({ name, receivedApp }); + }; + } + + const routes = requireWithMocks('../lib/routes/index', { + [require.resolve('../lib/routes/conversations')]: { setupConversationRoutes: makeSetup('conversations') }, + [require.resolve('../lib/routes/files')]: { setupFileRoutes: makeSetup('files') }, + [require.resolve('../lib/routes/git')]: { setupGitRoutes: makeSetup('git') }, + [require.resolve('../lib/routes/memory')]: { setupMemoryRoutes: makeSetup('memory') }, + [require.resolve('../lib/routes/capabilities')]: { setupCapabilitiesRoutes: makeSetup('capabilities') }, + [require.resolve('../lib/routes/preview')]: { setupPreviewRoutes: makeSetup('preview') }, + [require.resolve('../lib/routes/duckdb')]: { setupDuckDBRoutes: makeSetup('duckdb') }, + [require.resolve('../lib/routes/bigquery')]: { setupBigQueryRoutes: makeSetup('bigquery') }, + [require.resolve('../lib/routes/workflow')]: { setupWorkflowRoutes: makeSetup('workflow') }, + }, __filename); + + routes.setupRoutes(app); + + assert.deepEqual( + calls.map((entry) => entry.name), + ['conversations', 'files', 'git', 'memory', 'capabilities', 'preview', 'duckdb', 'bigquery', 'workflow'] + ); + assert.equal(calls.every((entry) => entry.receivedApp === app), true); + }); +}); diff --git a/test/workflow-routes.test.js b/test/workflow-routes.test.js new file mode 100644 index 0000000..1128614 --- /dev/null +++ b/test/workflow-routes.test.js @@ -0,0 +1,245 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); + +const { requireWithMocks } = require('./helpers/require-with-mocks.cjs'); + +async function startServer(app) { + return new Promise((resolve) => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function stopServer(server) { + if (!server) return; + await new Promise((resolve) => server.close(resolve)); +} + +async function requestJson(baseUrl, method, routePath, body) { + const response = await fetch(`${baseUrl}${routePath}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body == null ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { + status: response.status, + body: text ? JSON.parse(text) : null, + }; +} + +function createWorkflowMocks() { + const conversations = new Map([ + ['conv-1', { id: 'conv-1', name: 'Primary conversation', cwd: '/repo' }], + ['conv-2', { id: 'conv-2', name: 'Secondary conversation', cwd: '/repo/sub' }], + ]); + const locksByCwd = new Map(); + const patchesById = new Map(); + let patchCounter = 0; + let submitPayload = null; + + const locksApi = { + acquireLock(cwd, conversationId) { + const existing = locksByCwd.get(cwd); + if (existing && existing.writerConversationId !== conversationId) { + return { ok: false, code: 'WRITE_LOCKED', error: 'Repository is locked by another conversation', lock: existing }; + } + const lock = { + cwd, + writerConversationId: conversationId || null, + expiresAt: Date.now() + 30000, + }; + locksByCwd.set(cwd, lock); + return { ok: true, lock }; + }, + releaseLock(cwd, conversationId, { force } = {}) { + const existing = locksByCwd.get(cwd); + if (!existing) { + return { ok: false, code: 'LOCK_NOT_FOUND', error: 'Lock not found', lock: null }; + } + if (!force && existing.writerConversationId !== conversationId) { + return { ok: false, code: 'WRITE_LOCKED', error: 'Repository is locked by another conversation', lock: existing }; + } + locksByCwd.delete(cwd); + return { ok: true, released: true }; + }, + heartbeatLock(cwd, conversationId) { + const existing = locksByCwd.get(cwd); + if (!existing) { + return { ok: false, code: 'LOCK_NOT_FOUND', error: 'Lock not found', lock: null }; + } + if (existing.writerConversationId !== conversationId) { + return { ok: false, code: 'WRITE_LOCKED', error: 'Repository is locked by another conversation', lock: existing }; + } + existing.expiresAt = Date.now() + 30000; + return { ok: true, lock: existing }; + }, + getLock(cwd) { + return locksByCwd.get(cwd) || null; + }, + canWrite(cwd, conversationId) { + const existing = locksByCwd.get(cwd); + return !existing || existing.writerConversationId === conversationId; + }, + }; + + const patchApi = { + async listPatches(cwd) { + const items = Array.from(patchesById.values()); + if (!cwd) return items; + return items.filter((item) => item.cwd === cwd); + }, + async submitPatch(payload) { + submitPayload = payload; + if (!payload.cwd) { + return { ok: false, code: 'PATCH_CWD_REQUIRED', error: 'cwd required' }; + } + if (!payload.diff) { + return { ok: false, code: 'PATCH_DIFF_REQUIRED', error: 'diff required' }; + } + patchCounter += 1; + const id = `patch-${patchCounter}`; + const item = { + id, + cwd: payload.cwd, + conversationId: payload.conversationId || null, + title: payload.title || 'Untitled patch', + diff: payload.diff, + status: 'queued', + }; + patchesById.set(id, item); + return { ok: true, item }; + }, + async getPatchById(id) { + return patchesById.get(id) || null; + }, + async applyPatch(id, { appliedBy } = {}) { + const item = patchesById.get(id); + if (!item) { + return { ok: false, code: 'PATCH_NOT_FOUND', error: 'Patch not found' }; + } + item.status = 'applied'; + item.applyMeta = { appliedBy: appliedBy || null }; + return { ok: true, item }; + }, + async rejectPatch(id, { rejectedBy, reason } = {}) { + const item = patchesById.get(id); + if (!item) { + return { ok: false, code: 'PATCH_NOT_FOUND', error: 'Patch not found' }; + } + item.status = 'rejected'; + item.applyMeta = { rejectedBy: rejectedBy || null, reason: reason || null }; + return { ok: true, item }; + }, + }; + + const setupModule = requireWithMocks('../lib/routes/workflow', { + [require.resolve('../lib/data')]: { conversations }, + [require.resolve('../lib/routes/helpers')]: { + withErrorHandling(handler) { + return async (req, res) => { + try { + await handler(req, res); + } catch (err) { + res.status(500).json({ error: err?.message || 'handler failed' }); + } + }; + }, + }, + [require.resolve('../lib/workflow/locks')]: locksApi, + [require.resolve('../lib/workflow/patch-queue')]: patchApi, + }, __filename); + + return { + setupWorkflowRoutes: setupModule.setupWorkflowRoutes, + state: { + conversations, + locksByCwd, + patchesById, + getSubmitPayload: () => submitPayload, + }, + }; +} + +describe('workflow routes', () => { + let server; + let baseUrl; + let state; + + beforeEach(async () => { + const module = createWorkflowMocks(); + state = module.state; + const app = express(); + app.use(express.json()); + module.setupWorkflowRoutes(app); + server = await startServer(app); + baseUrl = `http://127.0.0.1:${server.address().port}`; + }); + + afterEach(async () => { + await stopServer(server); + server = null; + baseUrl = null; + state = null; + }); + + it('returns validation error when acquiring a lock without cwd', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/workflow/lock/acquire', {}); + assert.equal(response.status, 400); + assert.equal(response.body.error, 'cwd required'); + }); + + it('returns blocker conversation metadata on lock conflicts', async () => { + const first = await requestJson(baseUrl, 'POST', '/api/workflow/lock/acquire', { + cwd: '/repo', + conversationId: 'conv-1', + }); + assert.equal(first.status, 200); + + const conflict = await requestJson(baseUrl, 'POST', '/api/workflow/lock/acquire', { + cwd: '/repo', + conversationId: 'conv-2', + }); + assert.equal(conflict.status, 409); + assert.equal(conflict.body.code, 'WRITE_LOCKED'); + assert.equal(conflict.body.blockerConversationId, 'conv-1'); + assert.equal(conflict.body.blockerConversationName, 'Primary conversation'); + }); + + it('infers patch cwd from conversation when cwd is omitted', async () => { + const response = await requestJson(baseUrl, 'POST', '/api/workflow/patches', { + conversationId: 'conv-2', + title: 'Test patch', + diff: 'diff --git a/a.txt b/a.txt\n', + }); + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); + assert.equal(response.body.patch.cwd, '/repo/sub'); + assert.equal(state.getSubmitPayload().cwd, '/repo/sub'); + }); + + it('returns apply conflict details when writer lock is owned by another conversation', async () => { + const created = await requestJson(baseUrl, 'POST', '/api/workflow/patches', { + cwd: '/repo', + conversationId: 'conv-2', + title: 'Patch needing lock', + diff: 'diff --git a/a.txt b/a.txt\n', + }); + assert.equal(created.status, 200); + const patchId = created.body.patch.id; + + const locked = await requestJson(baseUrl, 'POST', '/api/workflow/lock/acquire', { + cwd: '/repo', + conversationId: 'conv-1', + }); + assert.equal(locked.status, 200); + + const conflict = await requestJson(baseUrl, 'POST', `/api/workflow/patches/${patchId}/apply`, { + conversationId: 'conv-2', + }); + assert.equal(conflict.status, 409); + assert.equal(conflict.body.code, 'WRITE_LOCKED'); + assert.equal(conflict.body.blockerConversationId, 'conv-1'); + assert.equal(conflict.body.blockerConversationName, 'Primary conversation'); + }); +});