diff --git a/CHANGELOG.md b/CHANGELOG.md index ee069793..08fc23d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # aicodeman +## 0.3.0 + +### Minor Changes + +- QR code authentication for tunnel access, 7-phase codebase refactor (route extraction, type domain modules, frontend module split, config consolidation, managed timers, test infrastructure), overlay rendering fixes, and security hardening + ## 0.2.9 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index f0bd738a..512cd5cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ When user says "COM": 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.2.9 (must match `package.json`) +**Version**: 0.3.0 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index ca83145c..620d9788 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.2.9", + "version": "0.3.0", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index d006ff0a..a1dc8668 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -41,10 +41,7 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '.. import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; import { RunSummaryTracker } from '../../run-summary.js'; -import { - MAX_INPUT_LENGTH, - MAX_SESSION_NAME_LENGTH, -} from '../../config/terminal-limits.js'; +import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) // eslint-disable-next-line no-control-regex diff --git a/test-bg-deep.mjs b/test-bg-deep.mjs new file mode 100644 index 00000000..4e00ef33 --- /dev/null +++ b/test-bg-deep.mjs @@ -0,0 +1,67 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); +await page.locator('.session-tab').first().click(); +await page.waitForTimeout(4000); + +// Check ALL background colors in the xterm rendering stack +const bgInfo = await page.evaluate(() => { + const term = window.app?.terminal; + if (!term) return { error: 'no terminal' }; + + const el = term.element; + if (!el) return { error: 'no element' }; + + const results = {}; + + // Walk up from xterm-screen to find who provides the background + const screen = el.querySelector('.xterm-screen'); + const viewport = el.querySelector('.xterm-viewport'); + const rows = el.querySelector('.xterm-rows'); + + const elements = { + '.xterm (term.element)': el, + '.xterm-viewport': viewport, + '.xterm-screen': screen, + '.xterm-rows': rows, + }; + + // Also check parent elements + let parent = el.parentElement; + let depth = 0; + while (parent && depth < 5) { + elements[`parent-${depth} (${parent.tagName}.${parent.className?.split(' ')[0] || ''})`] = parent; + parent = parent.parentElement; + depth++; + } + + for (const [name, elem] of Object.entries(elements)) { + if (!elem) { results[name] = 'not found'; continue; } + const cs = getComputedStyle(elem); + results[name] = { + background: cs.background?.slice(0, 80), + backgroundColor: cs.backgroundColor, + inlineStyle: elem.style.backgroundColor || elem.style.background || '(none)', + }; + } + + // Also check if xterm-viewport has inline style set by xterm.js + if (viewport) { + results['viewport-inline-full'] = viewport.style.cssText?.slice(0, 200); + } + + // Theme config + results.theme = term.options?.theme; + + // Overlay bg + results.overlayBg = window.app?._localEchoOverlay?._font?.backgroundColor; + + return results; +}); + +console.log(JSON.stringify(bgInfo, null, 2)); +await browser.close(); diff --git a/test-bg-mismatch.mjs b/test-bg-mismatch.mjs new file mode 100644 index 00000000..43583183 --- /dev/null +++ b/test-bg-mismatch.mjs @@ -0,0 +1,85 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); +await page.locator('.session-tab').first().click(); +await page.waitForTimeout(4000); + +// Sample the ACTUAL canvas pixel color at an empty area +const colorInfo = await page.evaluate(() => { + const term = window.app?.terminal; + if (!term) return { error: 'no terminal' }; + + // Find the WebGL canvas + const screen = term.element?.querySelector('.xterm-screen'); + const canvases = screen?.querySelectorAll('canvas'); + + const results = {}; + + for (const canvas of canvases || []) { + const ctx = canvas.getContext('2d') || canvas.getContext('webgl') || canvas.getContext('webgl2'); + const ctxType = ctx?.constructor?.name; + + if (ctx && (ctxType === 'CanvasRenderingContext2D')) { + // 2D context — can read pixels directly + try { + const pixel = ctx.getImageData(10, 10, 1, 1).data; + results['2d'] = { + r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3], + hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''), + }; + } catch (e) { + results['2d'] = { error: e.message }; + } + } else if (ctx) { + // WebGL context — use readPixels + try { + const gl = ctx; + const pixel = new Uint8Array(4); + // Read from an empty area (bottom-left corner, row 0 col 0) + gl.readPixels(10, canvas.height - 10, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + results['webgl'] = { + r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3], + hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''), + }; + } catch (e) { + results['webgl'] = { error: e.message }; + } + } + } + + // Also check what the terminal theme says + results.theme = { + background: term.options?.theme?.background, + foreground: term.options?.theme?.foreground, + }; + + // Check the overlay's configured background + const overlay = window.app?._localEchoOverlay; + results.overlayBg = overlay?._font?.backgroundColor; + + // Check xterm-rows computed style + const rows = term.element?.querySelector('.xterm-rows'); + if (rows) { + results.rowsBg = getComputedStyle(rows).backgroundColor; + } + + // Check .xterm element bg + const xtermEl = term.element; + if (xtermEl) { + results.xtermBg = getComputedStyle(xtermEl).backgroundColor; + } + + // Canvas count and types + results.canvasCount = canvases?.length; + results.canvasClasses = Array.from(canvases || []).map(c => c.className); + + return results; +}); + +console.log(JSON.stringify(colorInfo, null, 2)); +await browser.close(); +console.log('Done'); diff --git a/test-dims.mjs b/test-dims.mjs new file mode 100644 index 00000000..16045169 --- /dev/null +++ b/test-dims.mjs @@ -0,0 +1,48 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); + +const firstTab = page.locator('.session-tab').first(); +await firstTab.click(); +await page.waitForTimeout(5000); + +const info = await page.evaluate(() => { + const term = window.app?.terminal; + if (!term) return { error: 'no app.terminal' }; + + const core = term._core; + const renderService = core?._renderService; + const dims = renderService?.dimensions; + + if (!dims) return { error: 'no dimensions', hasCore: !!core, hasRenderService: !!renderService }; + + return { + css: { + cell: dims.css?.cell, + char: dims.css?.char, + canvas: dims.css?.canvas, + }, + device: { + cell: dims.device?.cell, + char: dims.device?.char, + canvas: dims.device?.canvas, + }, + topLevelKeys: Object.keys(dims), + cssKeys: dims.css ? Object.keys(dims.css) : [], + deviceKeys: dims.device ? Object.keys(dims.device) : [], + cssAllDetail: dims.css ? Object.fromEntries( + Object.entries(dims.css).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v]) + ) : null, + deviceAllDetail: dims.device ? Object.fromEntries( + Object.entries(dims.device).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v]) + ) : null, + dpr: window.devicePixelRatio, + }; +}); + +console.log(JSON.stringify(info, null, 2)); +await browser.close(); diff --git a/test-line-artifact.mjs b/test-line-artifact.mjs new file mode 100644 index 00000000..43648a0c --- /dev/null +++ b/test-line-artifact.mjs @@ -0,0 +1,87 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true, deviceScaleFactor: 2 }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); +await page.locator('.session-tab').first().click(); +await page.waitForTimeout(4000); + +await page.evaluate(() => { + const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); + settings.localEchoEnabled = true; + localStorage.setItem('codeman-settings', JSON.stringify(settings)); + if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState(); +}); +await page.waitForTimeout(500); + +// Force render and capture zoomed area of just the overlay +const result = await page.evaluate(() => { + const overlay = window.app?._localEchoOverlay; + if (!overlay || !overlay._overlay) return { error: 'no overlay' }; + + overlay._lastPromptPos = { row: 5, col: 0 }; + overlay._pendingText = 'h'; + overlay._lastRenderKey = ''; + overlay._render(); + + const container = overlay._overlay; + const lineDiv = container.querySelector('div'); + const charSpan = container.querySelector('div > span'); + + // Get precise rects + const lineDivRect = lineDiv?.getBoundingClientRect(); + const charSpanRect = charSpan?.getBoundingClientRect(); + const screenRect = document.querySelector('.xterm-screen')?.getBoundingClientRect(); + + // Check what's right below the line div + const belowY = (lineDivRect?.bottom || 0) + 1; + const belowX = lineDivRect?.left || 0; + const elBelow = document.elementFromPoint(belowX, belowY); + + return { + dpr: window.devicePixelRatio, + lineDiv: { + rect: lineDivRect, + bg: lineDiv?.style.backgroundColor, + height: lineDiv?.style.height, + }, + charSpan: { + rect: charSpanRect, + transform: charSpan?.style.transform, + // Check if span bottom exceeds lineDiv bottom + extendsBelow: charSpanRect && lineDivRect ? + (charSpanRect.bottom - lineDivRect.bottom).toFixed(2) : 'N/A', + }, + elBelow: elBelow ? { + tag: elBelow.tagName, + class: elBelow.className, + } : null, + // Check the exact background color of the canvas + canvasBg: document.querySelector('.xterm-screen')?.style.backgroundColor, + themeBg: window.app?.terminal?.options?.theme?.background, + overlayBg: lineDiv?.style.backgroundColor, + }; +}); +console.log(JSON.stringify(result, null, 2)); + +// Take tight screenshot around the first char area +const screenEl = page.locator('.xterm-screen').first(); +const screenBox = await screenEl.boundingBox(); +if (screenBox) { + // Crop to just the overlay area (row 5, first few columns) + const cellH = 19; + const promptRow = 5; + const cropY = screenBox.y + (promptRow - 1) * cellH; + const cropH = cellH * 3; + await page.screenshot({ + path: '/tmp/first-char-zoomed.png', + clip: { x: screenBox.x, y: cropY, width: 200, height: cropH }, + }); + console.log('Zoomed screenshot saved'); +} + +await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); }); +await browser.close(); +console.log('Done'); diff --git a/test-overlay-screenshot.mjs b/test-overlay-screenshot.mjs new file mode 100644 index 00000000..385888e5 --- /dev/null +++ b/test-overlay-screenshot.mjs @@ -0,0 +1,116 @@ +import { chromium } from 'playwright'; + +const SESSION_ID = '9270f4e4-84f7-414c-b901-837f12c30e59'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +page.on('console', msg => { + if (msg.type() === 'error') console.log('CONSOLE ERROR:', msg.text()); +}); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(4000); + +// Click on test session tab +const tab = page.locator(`.session-tab[data-id="${SESSION_ID}"]`); +if (await tab.isVisible({ timeout: 3000 }).catch(() => false)) { + await tab.click(); + await page.waitForTimeout(2000); +} else { + console.log('Tab not found, using whatever is active'); +} + +await page.waitForTimeout(2000); + +// Ensure local echo is OFF +const localEchoToggle = page.locator('#localEchoToggle'); +const isChecked = await localEchoToggle.isChecked().catch(() => false); +console.log('Local echo initially:', isChecked); + +if (isChecked) { + await localEchoToggle.click(); + await page.waitForTimeout(500); +} + +// Type with echo OFF +await page.locator('.xterm-helper-textarea').first().focus(); +await page.waitForTimeout(300); +await page.keyboard.type('hello test', { delay: 80 }); +await page.waitForTimeout(1000); + +const termScreen = page.locator('.xterm-screen').first(); +await termScreen.screenshot({ path: '/tmp/echo-off-term.png' }); +console.log('Saved /tmp/echo-off-term.png'); + +// Clear +for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace'); +await page.waitForTimeout(500); + +// Enable local echo +await localEchoToggle.click(); +await page.waitForTimeout(500); +console.log('Local echo now:', await localEchoToggle.isChecked().catch(() => false)); + +// Type with echo ON +await page.locator('.xterm-helper-textarea').first().focus(); +await page.waitForTimeout(300); +await page.keyboard.type('hello test', { delay: 80 }); +await page.waitForTimeout(1500); + +await termScreen.screenshot({ path: '/tmp/echo-on-term.png' }); +console.log('Saved /tmp/echo-on-term.png'); +await page.screenshot({ path: '/tmp/echo-on-full.png' }); + +// Inspect overlay DOM +const overlayInfo = await page.evaluate(() => { + const overlay = document.querySelector('.zl-overlay'); + if (!overlay) return { found: false }; + + const firstDiv = overlay.querySelector('div'); + const spans = overlay.querySelectorAll('span'); + const firstCharSpan = Array.from(spans).find(s => s.textContent && s.textContent.trim()); + + return { + found: true, + display: overlay.style.display, + top: overlay.style.top, + containerOverflow: overlay.style.overflow, + containerHeight: overlay.style.height, + firstDiv: firstDiv ? { + height: firstDiv.style.height, + top: firstDiv.style.top, + overflow: firstDiv.style.overflow, + backgroundColor: firstDiv.style.backgroundColor, + } : null, + firstCharSpan: firstCharSpan ? { + text: firstCharSpan.textContent, + transform: firstCharSpan.style.transform, + top: firstCharSpan.style.top, + height: firstCharSpan.style.height, + lineHeight: firstCharSpan.style.lineHeight, + width: firstCharSpan.style.width, + } : null, + spanCount: spans.length, + }; +}); +console.log('\nOverlay info:', JSON.stringify(overlayInfo, null, 2)); + +// Cell dimensions +const cellInfo = await page.evaluate(() => { + if (window.app?.sessions) { + for (const [, s] of Object.entries(window.app.sessions)) { + const dims = s.terminal?._core?._renderService?.dimensions; + if (dims) return { css: dims.css?.cell, device_char: dims.device?.char }; + } + } + return { error: 'not found' }; +}); +console.log('Cell dims:', JSON.stringify(cellInfo, null, 2)); + +// Cleanup +await page.evaluate(async (id) => { + await fetch('/api/sessions/' + id, { method: 'DELETE' }); +}, SESSION_ID); + +await browser.close(); +console.log('\nDone'); diff --git a/test-overlay2.mjs b/test-overlay2.mjs new file mode 100644 index 00000000..13a9e0e6 --- /dev/null +++ b/test-overlay2.mjs @@ -0,0 +1,114 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +page.on('console', msg => console.log('CONSOLE:', msg.type(), msg.text())); +page.on('pageerror', err => console.log('PAGE ERROR:', err.message)); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(4000); + +// Create a fresh session +const resp = await page.evaluate(async () => { + const r = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'ov-test', mode: 'claude' }) + }); + return r.json(); +}); +const sid = resp.session?.id; +console.log('Session:', sid?.slice(0, 8)); +await page.waitForTimeout(3000); + +// Click on it +await page.locator(`.session-tab[data-id="${sid}"]`).click(); +await page.waitForTimeout(3000); + +// Check for the local echo toggle +const toggle = page.locator('#localEchoToggle'); +const toggleVisible = await toggle.isVisible().catch(() => false); +console.log('Toggle visible:', toggleVisible); + +if (!toggleVisible) { + // Maybe need to open settings or it's somewhere else + console.log('Looking for local echo toggle...'); + const all = await page.locator('input[type="checkbox"]').all(); + for (const cb of all) { + const id = await cb.getAttribute('id'); + const label = await cb.evaluate(el => el.parentElement?.textContent?.trim().slice(0, 30)); + console.log(' checkbox:', id, label); + } +} + +// Get cell dimensions first +const cellInfo = await page.evaluate(() => { + if (window.app?.sessions) { + for (const [id, s] of Object.entries(window.app.sessions)) { + const dims = s.terminal?._core?._renderService?.dimensions; + if (dims) return { + id: id.slice(0, 8), + cssCell: dims.css?.cell, + deviceChar: dims.device?.char, + actualFull: dims, + }; + } + } + return { error: 'no terminal' }; +}); +console.log('Cell info:', JSON.stringify(cellInfo, null, 2)); + +// Focus terminal and type with echo OFF +const textarea = page.locator('.xterm-helper-textarea').first(); +await textarea.focus(); +await page.waitForTimeout(500); +await page.keyboard.type('hello test', { delay: 80 }); +await page.waitForTimeout(1500); + +await page.screenshot({ path: '/tmp/echo-off-full.png' }); +console.log('Saved echo-off-full.png'); + +// Clear +for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace'); +await page.waitForTimeout(500); + +// Enable local echo +if (toggleVisible) { + await toggle.click(); + await page.waitForTimeout(500); + console.log('Toggled local echo ON'); +} + +// Type again +await textarea.focus(); +await page.waitForTimeout(300); +await page.keyboard.type('hello test', { delay: 80 }); +await page.waitForTimeout(1500); + +await page.screenshot({ path: '/tmp/echo-on-full.png' }); +console.log('Saved echo-on-full.png'); + +// Get overlay info +const oi = await page.evaluate(() => { + const o = document.querySelector('.zl-overlay'); + if (!o) return { found: false, html: 'none' }; + return { + found: true, + display: o.style.display, + top: o.style.top, + childCount: o.children.length, + innerHTML: o.innerHTML.slice(0, 500), + }; +}); +console.log('Overlay:', JSON.stringify(oi, null, 2)); + +// Cleanup +if (sid) { + await page.evaluate(async (id) => { + await fetch('/api/sessions/' + id, { method: 'DELETE' }); + }, sid); +} + +await browser.close(); +console.log('Done'); diff --git a/test-pixel.mjs b/test-pixel.mjs new file mode 100644 index 00000000..b9232fc0 --- /dev/null +++ b/test-pixel.mjs @@ -0,0 +1,109 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); + +// Click first tab +await page.locator('.session-tab').first().click(); +await page.waitForTimeout(4000); + +// Get terminal and enable local echo via JS +const result = await page.evaluate(() => { + const term = window.app.terminal; + if (!term) return { error: 'no terminal' }; + + const dims = term._core._renderService.dimensions; + + // Toggle local echo on via settings + const checkbox = document.getElementById('appSettingsLocalEcho'); + + return { + cellW: dims.css.cell.width, + cellH: dims.css.cell.height, + charTop: dims.device?.char?.top ?? 0, + charHeight: dims.device?.char?.height ?? 0, + dpr: window.devicePixelRatio, + fontSize: term.options.fontSize, + fontFamily: term.options.fontFamily, + localEchoCheckbox: !!checkbox, + localEchoChecked: checkbox?.checked, + }; +}); +console.log('Terminal info:', JSON.stringify(result, null, 2)); + +// Enable local echo via the app settings checkbox +await page.evaluate(() => { + const cb = document.getElementById('appSettingsLocalEcho'); + if (cb && !cb.checked) { + cb.click(); + cb.dispatchEvent(new Event('change', { bubbles: true })); + } +}); +await page.waitForTimeout(500); + +// Focus terminal and type +await page.locator('.xterm-helper-textarea').first().focus(); +await page.waitForTimeout(300); +await page.keyboard.type('hello', { delay: 100 }); +await page.waitForTimeout(1500); + +// Take screenshots and analyze pixel positions +const pixelInfo = await page.evaluate(() => { + const overlay = document.querySelector('.zl-overlay'); + if (!overlay) return { error: 'no overlay', overlayDisplay: overlay?.style?.display }; + + const spans = overlay.querySelectorAll('span'); + const firstCharSpan = Array.from(spans).find(s => s.textContent === 'h'); + + if (!firstCharSpan) { + return { error: 'no h span', spanCount: spans.length, innerHTML: overlay.innerHTML.slice(0, 200) }; + } + + const spanRect = firstCharSpan.getBoundingClientRect(); + const containerRect = overlay.getBoundingClientRect(); + const lineDiv = firstCharSpan.parentElement; + const lineDivRect = lineDiv.getBoundingClientRect(); + + // Get the canvas position for comparison + const canvasEl = document.querySelector('.xterm-screen canvas'); + const canvasRect = canvasEl?.getBoundingClientRect(); + + return { + span: { + top: spanRect.top, + bottom: spanRect.bottom, + height: spanRect.height, + relativeTop: spanRect.top - lineDivRect.top, + }, + lineDiv: { + top: lineDivRect.top, + bottom: lineDivRect.bottom, + height: lineDivRect.height, + }, + container: { + top: containerRect.top, + height: containerRect.height, + }, + canvas: canvasRect ? { + top: canvasRect.top, + } : null, + spanStyle: { + transform: firstCharSpan.style.transform, + top: firstCharSpan.style.top, + height: firstCharSpan.style.height, + lineHeight: firstCharSpan.style.lineHeight, + }, + }; +}); +console.log('Pixel info:', JSON.stringify(pixelInfo, null, 2)); + +// Screenshot the terminal area +const termScreen = page.locator('.xterm-screen').first(); +await termScreen.screenshot({ path: '/tmp/overlay-test-zoomed.png' }); +console.log('Screenshot saved to /tmp/overlay-test-zoomed.png'); + +await browser.close(); +console.log('Done'); diff --git a/test-sc.mjs b/test-sc.mjs new file mode 100644 index 00000000..b6a01fa5 --- /dev/null +++ b/test-sc.mjs @@ -0,0 +1,69 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); +await page.locator('.session-tab').first().click(); +await page.waitForTimeout(4000); + +// Enable local echo +await page.evaluate(() => { + const s = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); + s.localEchoEnabled = true; + localStorage.setItem('codeman-settings', JSON.stringify(s)); + window.app?.updateLocalEchoState?.(); +}); +await page.waitForTimeout(500); + +// Force render overlay with single char at a realistic prompt position +await page.evaluate(() => { + const ov = window.app?._localEchoOverlay; + if (!ov) return; + // Find actual bottom row for a realistic position + const buf = ov._terminal?.buffer?.active; + const row = buf ? Math.max(0, buf.baseY + ov._terminal.rows - 2) - buf.viewportY : 5; + ov._lastPromptPos = { row, col: 0 }; + ov._pendingText = 'hello test'; + ov._lastRenderKey = ''; + ov._render(); +}); +await page.waitForTimeout(500); + +// Take full-page and zoomed screenshots +await page.screenshot({ path: '/tmp/sc-full.png' }); + +// Zoom into overlay area — find where the overlay actually is +const overlayRect = await page.evaluate(() => { + const ov = window.app?._localEchoOverlay?._overlay; + if (!ov) return null; + const r = ov.getBoundingClientRect(); + const lineDiv = ov.querySelector('div'); + const lr = lineDiv?.getBoundingClientRect(); + return { + container: { x: r.x, y: r.y, w: r.width, h: r.height }, + lineDiv: lr ? { x: lr.x, y: lr.y, w: lr.width, h: lr.height } : null, + }; +}); +console.log('Overlay rect:', JSON.stringify(overlayRect)); + +if (overlayRect?.lineDiv) { + const ld = overlayRect.lineDiv; + // Capture area around the overlay: 1 row above, the overlay, 1 row below + const margin = 20; // ~1 cell height + await page.screenshot({ + path: '/tmp/sc-overlay-zoomed.png', + clip: { + x: Math.max(0, ld.x - 5), + y: Math.max(0, ld.y - margin), + width: Math.min(400, ld.w), + height: ld.h + margin * 2 + 5, + }, + }); + console.log('Zoomed screenshot saved'); +} + +await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); }); +await browser.close(); +console.log('Done'); diff --git a/test-verify-fix.mjs b/test-verify-fix.mjs new file mode 100644 index 00000000..a147d2c0 --- /dev/null +++ b/test-verify-fix.mjs @@ -0,0 +1,69 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); + +await page.locator('.session-tab').first().click(); +await page.waitForTimeout(4000); + +// Enable local echo and update state +await page.evaluate(() => { + const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); + settings.localEchoEnabled = true; + localStorage.setItem('codeman-settings', JSON.stringify(settings)); + if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState(); +}); +await page.waitForTimeout(500); + +// Force render with fake prompt position and inspect +const result = await page.evaluate(() => { + const overlay = window.app?._localEchoOverlay; + if (!overlay || !overlay._overlay) return { error: 'no overlay' }; + + const term = overlay._terminal; + const dims = term._core._renderService.dimensions; + const cellH = dims.css.cell.height; + const charTop = dims.device?.char?.top ?? 0; + const charHeight = dims.device?.char?.height ?? cellH; + + // Calculate the correction + const cssCenter = (cellH - charHeight) / 2; + const correction = charTop - cssCenter; + + // Force render + overlay._lastPromptPos = { row: 5, col: 0 }; + overlay._pendingText = 'hello test'; + overlay._lastRenderKey = ''; + overlay._render(); + + const spans = overlay._overlay.querySelectorAll('span'); + const firstChar = Array.from(spans).find(s => s.textContent === 'h'); + + return { + cellH, charTop, charHeight, + cssCenter, + correction: correction.toFixed(2), + firstChar: firstChar ? { + top: firstChar.style.top, + height: firstChar.style.height, + lineHeight: firstChar.style.lineHeight, + transform: firstChar.style.transform, + } : null, + }; +}); +console.log('Fix result:', JSON.stringify(result, null, 2)); + +// Take screenshot +await page.locator('.xterm-screen').first().screenshot({ path: '/tmp/fix-verify-term.png' }); +console.log('Screenshot saved'); + +// Clean up +await page.evaluate(() => { + window.app?._localEchoOverlay?.clear(); +}); + +await browser.close(); +console.log('Done'); diff --git a/test-verify-no-overhang.mjs b/test-verify-no-overhang.mjs new file mode 100644 index 00000000..80589ebe --- /dev/null +++ b/test-verify-no-overhang.mjs @@ -0,0 +1,61 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + +await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(6000); +await page.locator('.session-tab').first().click(); +await page.waitForTimeout(4000); + +await page.evaluate(() => { + const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); + settings.localEchoEnabled = true; + localStorage.setItem('codeman-settings', JSON.stringify(settings)); + if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState(); +}); + +const result = await page.evaluate(() => { + const overlay = window.app?._localEchoOverlay; + if (!overlay) return { error: 'no overlay' }; + + overlay._lastPromptPos = { row: 5, col: 0 }; + overlay._pendingText = 'hello test'; + overlay._lastRenderKey = ''; + overlay._render(); + + const container = overlay._overlay; + const lineDiv = container.querySelector('div'); + const charSpan = container.querySelector('div > span'); + + const lineDivRect = lineDiv?.getBoundingClientRect(); + const charSpanRect = charSpan?.getBoundingClientRect(); + + return { + lineDiv: { bottom: lineDivRect?.bottom, height: lineDivRect?.height }, + charSpan: { + bottom: charSpanRect?.bottom, + height: charSpanRect?.height, + transform: charSpan?.style.transform, + top: charSpan?.style.top, + lineHeight: charSpan?.style.lineHeight, + }, + extendsBelow: charSpanRect && lineDivRect ? + (charSpanRect.bottom - lineDivRect.bottom).toFixed(4) : 'N/A', + }; +}); +console.log(JSON.stringify(result, null, 2)); + +// Screenshot +const screenEl = page.locator('.xterm-screen').first(); +const screenBox = await screenEl.boundingBox(); +if (screenBox) { + await page.screenshot({ + path: '/tmp/no-overhang-zoomed.png', + clip: { x: screenBox.x, y: screenBox.y + 4 * 19, width: 200, height: 57 }, + }); +} + +await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); }); +await browser.close(); +console.log('Done'); diff --git a/test/routes/case-routes.test.ts b/test/routes/case-routes.test.ts new file mode 100644 index 00000000..07741f99 --- /dev/null +++ b/test/routes/case-routes.test.ts @@ -0,0 +1,515 @@ +/** + * @fileoverview Tests for case-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; + +// Mock filesystem modules +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + }; +}); + +vi.mock('node:fs/promises', () => ({ + default: { + readdir: vi.fn(async () => []), + readFile: vi.fn(async () => { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }), + writeFile: vi.fn(async () => {}), + }, +})); + +vi.mock('../../src/templates/claude-md.js', () => ({ + generateClaudeMd: vi.fn(() => '# CLAUDE.md\nGenerated content'), +})); + +vi.mock('../../src/hooks-config.js', () => ({ + writeHooksConfig: vi.fn(async () => {}), +})); + +// Import mocked modules for test control +import { existsSync, mkdirSync, readdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; + +const mockedExistsSync = vi.mocked(existsSync); +const mockedMkdirSync = vi.mocked(mkdirSync); +const mockedReaddirSync = vi.mocked(readdirSync); +const mockedReaddir = vi.mocked(fs.readdir); +const mockedReadFile = vi.mocked(fs.readFile); + +describe('case-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerCaseRoutes); + vi.clearAllMocks(); + + // Default: existsSync returns false, readFile throws ENOENT + mockedExistsSync.mockReturnValue(false); + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/cases ========== + + describe('GET /api/cases', () => { + it('returns empty array when no cases exist', async () => { + mockedReaddir.mockRejectedValue(new Error('ENOENT')); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toEqual([]); + }); + + it('returns cases from CASES_DIR', async () => { + mockedReaddir.mockResolvedValue([ + { name: 'my-case', isDirectory: () => true }, + { name: 'other-case', isDirectory: () => true }, + { name: 'readme.txt', isDirectory: () => false }, + ] as never); + // No CLAUDE.md exists + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toHaveLength(2); + expect(body[0].name).toBe('my-case'); + expect(body[1].name).toBe('other-case'); + expect(body[0].hasClaudeMd).toBe(false); + }); + + it('includes hasClaudeMd flag', async () => { + mockedReaddir.mockResolvedValue([ + { name: 'case-with-md', isDirectory: () => true }, + ] as never); + mockedExistsSync.mockReturnValue(true); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body[0].hasClaudeMd).toBe(true); + }); + + it('includes linked cases from linked-cases.json', async () => { + // CASES_DIR readdir returns one case + mockedReaddir.mockResolvedValue([ + { name: 'regular-case', isDirectory: () => true }, + ] as never); + // linked-cases.json is read second (after CASES_DIR readdir) + let readCallCount = 0; + mockedReadFile.mockImplementation(async () => { + readCallCount++; + if (readCallCount === 1) { + return JSON.stringify({ 'linked-project': '/home/user/projects/linked' }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + // existsSync: path exists for linked case, CLAUDE.md check + mockedExistsSync.mockImplementation((p: string) => { + if (typeof p === 'string' && p.includes('linked')) return true; + return false; + }); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + // Should have both regular and linked cases + expect(body.length).toBeGreaterThanOrEqual(1); + }); + }); + + // ========== POST /api/cases ========== + + describe('POST /api/cases', () => { + it('rejects invalid case name', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases', + payload: { name: 'invalid case name!!' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects missing name', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects path traversal in name', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases', + payload: { name: '../etc' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects duplicate case name', async () => { + mockedExistsSync.mockReturnValue(true); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases', + payload: { name: 'existing-case' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('already exists'); + }); + + it('creates case directory with CLAUDE.md and hooks config', async () => { + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases', + payload: { name: 'new-case', description: 'A new test case' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.case.name).toBe('new-case'); + expect(body.data.case.path).toContain('new-case'); + + // Verify directory creation + expect(mockedMkdirSync).toHaveBeenCalled(); + + // Verify broadcast + expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:created', expect.objectContaining({ name: 'new-case' })); + }); + }); + + // ========== POST /api/cases/link ========== + + describe('POST /api/cases/link', () => { + it('rejects invalid request body', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases/link', + payload: { name: 'bad name!' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects missing fields', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases/link', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns not found when folder does not exist', async () => { + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases/link', + payload: { name: 'my-project', path: '/nonexistent/path' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + + it('rejects when case name already exists in CASES_DIR', async () => { + // First call (expandedPath check) returns true, second (casePath check) also returns true + mockedExistsSync.mockReturnValue(true); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases/link', + payload: { name: 'existing-case', path: '/home/user/project' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('already exists'); + }); + + it('links folder successfully', async () => { + // expandedPath exists (first call), casePath does not (second call) + let callIdx = 0; + mockedExistsSync.mockImplementation(() => { + callIdx++; + return callIdx === 1; // first: folder exists, second: case dir doesn't + }); + // linked-cases.json doesn't exist yet + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cases/link', + payload: { name: 'linked-project', path: '/home/user/project' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.case.name).toBe('linked-project'); + expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:linked', expect.objectContaining({ name: 'linked-project' })); + }); + }); + + // ========== GET /api/cases/:name ========== + + describe('GET /api/cases/:name', () => { + it('returns linked case info', async () => { + mockedReadFile.mockResolvedValue(JSON.stringify({ 'my-case': '/home/user/my-case' }) as never); + mockedExistsSync.mockReturnValue(true); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.name).toBe('my-case'); + expect(body.linked).toBe(true); + }); + + it('returns CASES_DIR case when no linked case found', async () => { + // linked-cases.json read fails + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + // case dir exists + mockedExistsSync.mockReturnValue(true); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/regular-case', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.name).toBe('regular-case'); + }); + + it('returns error when case not found anywhere', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/nonexistent', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + }); + + // ========== GET /api/cases/:name/fix-plan ========== + + describe('GET /api/cases/:name/fix-plan', () => { + it('returns exists=false when no fix plan file', async () => { + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/fix-plan', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.exists).toBe(false); + expect(body.content).toBeNull(); + expect(body.todos).toEqual([]); + }); + + it('parses fix plan with todos and stats', async () => { + const fixPlanContent = [ + '# Fix Plan', + '## High Priority', + '- [ ] Fix critical bug', + '- [-] Working on auth', + '- [x] Setup database', + '## Standard', + '- [ ] Add logging', + '## Completed', + '- [x] Initial setup', + ].join('\n'); + + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockedExistsSync.mockImplementation((p: string) => { + if (typeof p === 'string' && p.includes('fix_plan')) return true; + return false; + }); + + // Override readFile for the fix plan read + mockedReadFile.mockImplementation(async (p: string) => { + if (typeof p === 'string' && p.includes('fix_plan')) { + return fixPlanContent as never; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/fix-plan', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.exists).toBe(true); + expect(body.todos.length).toBeGreaterThan(0); + expect(body.stats.total).toBeGreaterThan(0); + }); + }); + + // ========== GET /api/cases/:caseName/ralph-wizard/files ========== + + describe('GET /api/cases/:caseName/ralph-wizard/files', () => { + it('returns error when wizard directory not found', async () => { + mockedExistsSync.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/ralph-wizard/files', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + + it('rejects path traversal in case name', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/..%2F..%2Fetc/ralph-wizard/files', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns wizard files when directory exists', async () => { + mockedExistsSync.mockImplementation((p: string) => { + if (typeof p === 'string' && p.includes('ralph-wizard')) return true; + if (typeof p === 'string' && p.includes('prompt.md')) return true; + if (typeof p === 'string' && p.includes('result.json')) return true; + return false; + }); + mockedReaddirSync.mockReturnValue([ + { name: 'research', isDirectory: () => true }, + { name: 'planner', isDirectory: () => true }, + ] as never); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/ralph-wizard/files', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.files).toHaveLength(2); + expect(body.data.files[0].agentType).toBe('research'); + }); + }); + + // ========== GET /api/cases/:caseName/ralph-wizard/file/:filePath ========== + + describe('GET /api/cases/:caseName/ralph-wizard/file/:filePath', () => { + it('returns error for missing file', async () => { + mockedExistsSync.mockReturnValue(false); + mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns markdown file content', async () => { + mockedExistsSync.mockReturnValue(false); + mockedReadFile.mockResolvedValue('# Research Prompt\nContent here' as never); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.content).toContain('Research Prompt'); + expect(body.data.isJson).toBe(false); + }); + + it('parses JSON file content', async () => { + mockedExistsSync.mockReturnValue(false); + const jsonContent = JSON.stringify({ plan: 'test plan', steps: [1, 2, 3] }); + mockedReadFile.mockResolvedValue(jsonContent as never); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/ralph-wizard/file/planner%2Fresult.json', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.isJson).toBe(true); + expect(body.data.parsed.plan).toBe('test plan'); + }); + + it('sets no-cache headers', async () => { + mockedExistsSync.mockReturnValue(false); + mockedReadFile.mockResolvedValue('content' as never); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md', + }); + expect(res.headers['cache-control']).toContain('no-store'); + }); + }); +}); diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts new file mode 100644 index 00000000..3be7a310 --- /dev/null +++ b/test/routes/file-routes.test.ts @@ -0,0 +1,365 @@ +/** + * @fileoverview Tests for file-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; + +// Mock fs/promises for file operations +vi.mock('node:fs/promises', () => ({ + default: { + readdir: vi.fn(async () => []), + readFile: vi.fn(async () => 'file content'), + stat: vi.fn(async () => ({ size: 100, isFile: () => true })), + }, +})); + +// Mock realpathSync for symlink resolution +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpathSync: vi.fn((p: string) => p), + }; +}); + +// Mock fileStreamManager +vi.mock('../../src/file-stream-manager.js', () => ({ + fileStreamManager: { + createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })), + closeStream: vi.fn(() => true), + }, +})); + +import fs from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import { fileStreamManager } from '../../src/file-stream-manager.js'; + +const mockedReaddir = vi.mocked(fs.readdir); +const mockedReadFile = vi.mocked(fs.readFile); +const mockedStat = vi.mocked(fs.stat); +const mockedRealpathSync = vi.mocked(realpathSync); +const mockedFileStreamManager = vi.mocked(fileStreamManager); + +describe('file-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerFileRoutes); + vi.clearAllMocks(); + + // Default: realpathSync returns the path unchanged + mockedRealpathSync.mockImplementation((p: string) => p as never); + // Default stat + mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/sessions/:id/files ========== + + describe('GET /api/sessions/:id/files', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/files', + }); + expect(res.statusCode).toBe(404); + }); + + it('returns file tree for valid session', async () => { + mockedReaddir.mockResolvedValue([ + { name: 'src', isDirectory: () => true }, + { name: 'package.json', isDirectory: () => false, name_: 'package.json' }, + ] as never); + // Nested readdir for src/ returns empty + mockedReaddir.mockResolvedValueOnce([ + { name: 'src', isDirectory: () => true }, + { name: 'package.json', isDirectory: () => false }, + ] as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.root).toBe(harness.ctx._session.workingDir); + expect(body.data.tree).toBeDefined(); + }); + + it('respects depth parameter', async () => { + mockedReaddir.mockResolvedValue([] as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?depth=2`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + + it('excludes hidden files by default', async () => { + mockedReaddir.mockResolvedValue([ + { name: '.hidden', isDirectory: () => false }, + { name: 'visible.ts', isDirectory: () => false }, + ] as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + // Hidden files should be excluded + expect(body.data.totalFiles).toBe(1); + }); + + it('includes hidden files when showHidden=true', async () => { + mockedReaddir.mockResolvedValue([ + { name: '.hidden', isDirectory: () => false }, + { name: 'visible.ts', isDirectory: () => false }, + ] as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?showHidden=true`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.totalFiles).toBe(2); + }); + + it('excludes node_modules and .git directories', async () => { + let callCount = 0; + mockedReaddir.mockImplementation(async () => { + callCount++; + if (callCount === 1) { + return [ + { name: 'node_modules', isDirectory: () => true }, + { name: '.git', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + ] as never; + } + return [] as never; + }); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?showHidden=true`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + // node_modules and .git are in excludeDirs set — only src should be counted + expect(body.data.totalDirectories).toBe(1); // only src + }); + }); + + // ========== GET /api/sessions/:id/file-content ========== + + describe('GET /api/sessions/:id/file-content', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/file-content?path=test.ts', + }); + expect(res.statusCode).toBe(404); + }); + + it('returns error for missing path parameter', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('Missing path'); + }); + + it('returns text file content', async () => { + const fileContent = 'const x = 1;\nconst y = 2;\n'; + mockedReadFile.mockResolvedValue(fileContent as never); + mockedStat.mockResolvedValue({ size: fileContent.length } as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=src/test.ts`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.content).toBe(fileContent); + expect(body.data.extension).toBe('ts'); + }); + + it('returns binary metadata for image files', async () => { + mockedStat.mockResolvedValue({ size: 1024 } as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=logo.png`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.type).toBe('image'); + expect(body.data.url).toContain('file-raw'); + }); + + it('rejects path traversal attempts', async () => { + // realpathSync resolves the symlink to a path outside workingDir + mockedRealpathSync.mockReturnValue('/etc/passwd' as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=../../etc/passwd`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects files that are too large', async () => { + mockedStat.mockResolvedValue({ size: 20 * 1024 * 1024 } as never); // 20MB + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=large-file.txt`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('too large'); + }); + + it('truncates content when exceeding line limit', async () => { + const lines = Array.from({ length: 600 }, (_, i) => `line ${i + 1}`).join('\n'); + mockedReadFile.mockResolvedValue(lines as never); + mockedStat.mockResolvedValue({ size: lines.length } as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=big.txt&lines=100`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.truncated).toBe(true); + expect(body.data.totalLines).toBe(600); + }); + + it('returns file not found when realpathSync throws', async () => { + mockedRealpathSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=nonexistent.ts`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + }); + + // ========== GET /api/sessions/:id/file-raw ========== + + describe('GET /api/sessions/:id/file-raw', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/file-raw?path=test.png', + }); + expect(res.statusCode).toBe(404); + }); + + it('returns 400 for missing path parameter', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-raw`, + }); + expect(res.statusCode).toBe(400); + }); + + it('serves raw file with correct content type', async () => { + const content = Buffer.from('fake png data'); + mockedReadFile.mockResolvedValue(content as never); + mockedStat.mockResolvedValue({ size: content.length } as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=image.png`, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + }); + + it('rejects path traversal in raw file serving', async () => { + mockedRealpathSync.mockReturnValue('/etc/shadow' as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`, + }); + expect(res.statusCode).toBe(400); + }); + + it('rejects overly large raw files', async () => { + mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024 } as never); // 100MB + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=huge.bin`, + }); + expect(res.statusCode).toBe(400); + }); + }); + + // ========== DELETE /api/sessions/:id/tail-file/:streamId ========== + + describe('DELETE /api/sessions/:id/tail-file/:streamId', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/sessions/nonexistent/tail-file/stream-1', + }); + expect(res.statusCode).toBe(404); + }); + + it('closes an existing stream', async () => { + mockedFileStreamManager.closeStream.mockReturnValue(true); + + const res = await harness.app.inject({ + method: 'DELETE', + url: `/api/sessions/${harness.ctx._sessionId}/tail-file/stream-1`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(mockedFileStreamManager.closeStream).toHaveBeenCalledWith('stream-1'); + }); + + it('returns false for unknown stream', async () => { + mockedFileStreamManager.closeStream.mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'DELETE', + url: `/api/sessions/${harness.ctx._sessionId}/tail-file/nonexistent`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); +}); diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts new file mode 100644 index 00000000..68f9ab17 --- /dev/null +++ b/test/routes/hook-event-routes.test.ts @@ -0,0 +1,221 @@ +/** + * @fileoverview Tests for hook-event-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js'; + +describe('hook-event-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerHookEventRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== POST /api/hook-event ========== + + describe('POST /api/hook-event', () => { + it('accepts a valid hook event and broadcasts it', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'stop', + sessionId: harness.ctx._sessionId, + data: null, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + 'hook:stop', + expect.objectContaining({ sessionId: harness.ctx._sessionId }), + ); + }); + + it('sends push notifications for hook events', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'idle_prompt', + sessionId: harness.ctx._sessionId, + data: null, + }, + }); + expect(res.statusCode).toBe(200); + expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith( + 'hook:idle_prompt', + expect.objectContaining({ sessionId: harness.ctx._sessionId }), + ); + }); + + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'stop', + sessionId: 'nonexistent-session', + data: null, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toBeDefined(); + }); + + it('rejects invalid event type', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'invalid_event_type', + sessionId: harness.ctx._sessionId, + data: null, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects missing sessionId', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'stop', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('signals respawn controller on stop event', async () => { + const mockController = { + signalStopHook: vi.fn(), + signalElicitation: vi.fn(), + signalIdlePrompt: vi.fn(), + }; + harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'stop', + sessionId: harness.ctx._sessionId, + data: null, + }, + }); + expect(res.statusCode).toBe(200); + expect(mockController.signalStopHook).toHaveBeenCalled(); + }); + + it('signals respawn controller on elicitation_dialog event', async () => { + const mockController = { + signalStopHook: vi.fn(), + signalElicitation: vi.fn(), + signalIdlePrompt: vi.fn(), + }; + harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'elicitation_dialog', + sessionId: harness.ctx._sessionId, + data: null, + }, + }); + expect(res.statusCode).toBe(200); + expect(mockController.signalElicitation).toHaveBeenCalled(); + }); + + it('signals respawn controller on idle_prompt event', async () => { + const mockController = { + signalStopHook: vi.fn(), + signalElicitation: vi.fn(), + signalIdlePrompt: vi.fn(), + }; + harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'idle_prompt', + sessionId: harness.ctx._sessionId, + data: null, + }, + }); + expect(res.statusCode).toBe(200); + expect(mockController.signalIdlePrompt).toHaveBeenCalled(); + }); + + it('records hook event in run summary tracker', async () => { + const mockTracker = { recordHookEvent: vi.fn() }; + harness.ctx.runSummaryTrackers.set(harness.ctx._sessionId, mockTracker as never); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'stop', + sessionId: harness.ctx._sessionId, + data: { tool_name: 'bash' }, + }, + }); + expect(res.statusCode).toBe(200); + expect(mockTracker.recordHookEvent).toHaveBeenCalledWith( + 'stop', + expect.any(Object), + ); + }); + + it('starts transcript watcher when transcript_path is provided', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'stop', + sessionId: harness.ctx._sessionId, + data: { transcript_path: '/home/user/.claude/transcript.jsonl' }, + }, + }); + expect(res.statusCode).toBe(200); + expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith( + harness.ctx._sessionId, + '/home/user/.claude/transcript.jsonl', + ); + }); + + it('accepts valid data payload with extra fields', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { + event: 'permission_prompt', + sessionId: harness.ctx._sessionId, + data: { tool_name: 'bash', command: 'ls -la' }, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + }); +}); diff --git a/test/routes/mux-routes.test.ts b/test/routes/mux-routes.test.ts new file mode 100644 index 00000000..fada50a7 --- /dev/null +++ b/test/routes/mux-routes.test.ts @@ -0,0 +1,141 @@ +/** + * @fileoverview Tests for mux-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerMuxRoutes } from '../../src/web/routes/mux-routes.js'; + +describe('mux-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + // Add mux methods that mux-routes needs but mock-route-context doesn't provide + harness = await createRouteTestHarness(registerMuxRoutes); + harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [ + { name: 'codeman-abc', pid: 1234, created: Date.now() }, + ]); + harness.ctx.mux.isAvailable = vi.fn(() => true); + harness.ctx.mux.reconcileSessions = vi.fn(async () => ({ + orphaned: [], + missing: [], + reconciled: 0, + })); + harness.ctx.mux.startStatsCollection = vi.fn(); + harness.ctx.mux.stopStatsCollection = vi.fn(); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/mux-sessions ========== + + describe('GET /api/mux-sessions', () => { + it('returns mux sessions and availability', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/mux-sessions', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.muxAvailable).toBe(true); + expect(body.sessions).toHaveLength(1); + expect(body.sessions[0].name).toBe('codeman-abc'); + }); + + it('returns muxAvailable false when mux is unavailable', async () => { + harness.ctx.mux.isAvailable = vi.fn(() => false); + harness.ctx.mux.getSessionsWithStats = vi.fn(async () => []); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/mux-sessions', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.muxAvailable).toBe(false); + expect(body.sessions).toHaveLength(0); + }); + }); + + // ========== DELETE /api/mux-sessions/:sessionId ========== + + describe('DELETE /api/mux-sessions/:sessionId', () => { + it('kills a mux session and returns success', async () => { + harness.ctx.mux.killSession = vi.fn(async () => true); + + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/mux-sessions/codeman-abc', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.mux.killSession).toHaveBeenCalledWith('codeman-abc'); + }); + + it('returns success false when kill fails', async () => { + harness.ctx.mux.killSession = vi.fn(async () => false); + + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/mux-sessions/nonexistent', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/mux-sessions/reconcile ========== + + describe('POST /api/mux-sessions/reconcile', () => { + it('returns reconciliation result', async () => { + const reconcileResult = { orphaned: ['old-session'], missing: [], reconciled: 1 }; + harness.ctx.mux.reconcileSessions = vi.fn(async () => reconcileResult); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/mux-sessions/reconcile', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.orphaned).toEqual(['old-session']); + expect(body.reconciled).toBe(1); + }); + }); + + // ========== POST /api/mux-sessions/stats/start ========== + + describe('POST /api/mux-sessions/stats/start', () => { + it('starts stats collection', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/mux-sessions/stats/start', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.mux.startStatsCollection).toHaveBeenCalled(); + }); + }); + + // ========== POST /api/mux-sessions/stats/stop ========== + + describe('POST /api/mux-sessions/stats/stop', () => { + it('stops stats collection', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/mux-sessions/stats/stop', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.mux.stopStatsCollection).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/routes/plan-routes.test.ts b/test/routes/plan-routes.test.ts new file mode 100644 index 00000000..99a19ab8 --- /dev/null +++ b/test/routes/plan-routes.test.ts @@ -0,0 +1,410 @@ +/** + * @fileoverview Tests for plan-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerPlanRoutes } from '../../src/web/routes/plan-routes.js'; + +describe('plan-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerPlanRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== POST /api/cancel-plan-generation ========== + + describe('POST /api/cancel-plan-generation', () => { + it('returns not found when orchestratorId does not exist', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cancel-plan-generation', + payload: { orchestratorId: 'plan-nonexistent' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + + it('cancels a specific orchestrator', async () => { + const mockOrchestrator = { cancel: vi.fn(async () => {}) }; + harness.ctx.activePlanOrchestrators.set('plan-123', mockOrchestrator as never); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cancel-plan-generation', + payload: { orchestratorId: 'plan-123' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.cancelled).toBe('plan-123'); + expect(mockOrchestrator.cancel).toHaveBeenCalled(); + expect(harness.ctx.activePlanOrchestrators.has('plan-123')).toBe(false); + expect(harness.ctx.broadcast).toHaveBeenCalledWith('plan:cancelled', { orchestratorId: 'plan-123' }); + }); + + it('cancels all active orchestrators when no id provided', async () => { + const mock1 = { cancel: vi.fn(async () => {}) }; + const mock2 = { cancel: vi.fn(async () => {}) }; + harness.ctx.activePlanOrchestrators.set('plan-a', mock1 as never); + harness.ctx.activePlanOrchestrators.set('plan-b', mock2 as never); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cancel-plan-generation', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.cancelled).toContain('plan-a'); + expect(body.data.cancelled).toContain('plan-b'); + expect(mock1.cancel).toHaveBeenCalled(); + expect(mock2.cancel).toHaveBeenCalled(); + expect(harness.ctx.activePlanOrchestrators.size).toBe(0); + }); + + it('rejects invalid body', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/cancel-plan-generation', + payload: { orchestratorId: 12345 }, // should be string + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== PATCH /api/sessions/:id/plan/task/:taskId ========== + + describe('PATCH /api/sessions/:id/plan/task/:taskId', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'PATCH', + url: '/api/sessions/nonexistent/plan/task/task-1', + payload: { status: 'completed' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('returns error when ralph tracker not available', async () => { + // Default mock session has ralphTracker = null + const res = await harness.app.inject({ + method: 'PATCH', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`, + payload: { status: 'completed' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('Ralph tracker'); + }); + + it('updates a plan task successfully', async () => { + const mockTask = { id: 'task-1', content: 'Do something', status: 'completed', attempts: 1 }; + harness.ctx._session.ralphTracker = { + updatePlanTask: vi.fn(() => ({ success: true, task: mockTask })), + } as never; + + const res = await harness.app.inject({ + method: 'PATCH', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`, + payload: { status: 'completed' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.id).toBe('task-1'); + expect(body.data.status).toBe('completed'); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + 'session:planTaskUpdate', + expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' }), + ); + }); + + it('returns not found for unknown task id', async () => { + harness.ctx._session.ralphTracker = { + updatePlanTask: vi.fn(() => ({ success: false, error: 'Task not found' })), + } as never; + + const res = await harness.app.inject({ + method: 'PATCH', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task/nonexistent`, + payload: { status: 'failed', error: 'Something broke' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + + it('rejects invalid body', async () => { + harness.ctx._session.ralphTracker = { + updatePlanTask: vi.fn(), + } as never; + + const res = await harness.app.inject({ + method: 'PATCH', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`, + payload: { status: 'invalid_status' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/plan/checkpoint ========== + + describe('POST /api/sessions/:id/plan/checkpoint', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/plan/checkpoint', + }); + expect(res.statusCode).toBe(404); + }); + + it('returns error when ralph tracker not available', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/checkpoint`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('Ralph tracker'); + }); + + it('generates a checkpoint review', async () => { + const mockCheckpoint = { + completedCount: 5, + totalCount: 10, + failedTasks: [], + recommendations: ['Continue with P1 tasks'], + }; + harness.ctx._session.ralphTracker = { + generateCheckpointReview: vi.fn(() => mockCheckpoint), + } as never; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/checkpoint`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.completedCount).toBe(5); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + 'session:planCheckpoint', + expect.objectContaining({ sessionId: harness.ctx._sessionId }), + ); + }); + }); + + // ========== GET /api/sessions/:id/plan/history ========== + + describe('GET /api/sessions/:id/plan/history', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/plan/history', + }); + expect(res.statusCode).toBe(404); + }); + + it('returns error when ralph tracker not available', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/plan/history`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('Ralph tracker'); + }); + + it('returns plan version history', async () => { + const mockHistory = [ + { version: 1, timestamp: Date.now() - 60000, itemCount: 10 }, + { version: 2, timestamp: Date.now(), itemCount: 12 }, + ]; + harness.ctx._session.ralphTracker = { + getPlanHistory: vi.fn(() => mockHistory), + } as never; + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/plan/history`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveLength(2); + expect(body.data[1].version).toBe(2); + }); + }); + + // ========== POST /api/sessions/:id/plan/rollback/:version ========== + + describe('POST /api/sessions/:id/plan/rollback/:version', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/plan/rollback/1', + }); + expect(res.statusCode).toBe(404); + }); + + it('returns error when ralph tracker not available', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/rollback/1`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('Ralph tracker'); + }); + + it('rolls back to a previous version', async () => { + const mockPlan = [ + { id: 'task-1', content: 'Step 1', status: 'pending' }, + ]; + harness.ctx._session.ralphTracker = { + rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })), + } as never; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/rollback/1`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveLength(1); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + 'session:planRollback', + expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 }), + ); + }); + + it('returns error for nonexistent version', async () => { + harness.ctx._session.ralphTracker = { + rollbackToVersion: vi.fn(() => ({ success: false, error: 'Version not found' })), + } as never; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/rollback/999`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + }); + + // ========== POST /api/sessions/:id/plan/task ========== + + describe('POST /api/sessions/:id/plan/task', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/plan/task', + payload: { content: 'New task' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('returns error when ralph tracker not available', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task`, + payload: { content: 'New task' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('Ralph tracker'); + }); + + it('adds a new task to the plan', async () => { + const addedTask = { id: 'new-1', content: 'New task', status: 'pending', priority: 'P1' }; + harness.ctx._session.ralphTracker = { + addPlanTask: vi.fn(() => ({ task: addedTask })), + } as never; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task`, + payload: { content: 'New task', priority: 'P1' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.id).toBe('new-1'); + expect(body.data.content).toBe('New task'); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + 'session:planTaskAdded', + expect.objectContaining({ sessionId: harness.ctx._sessionId }), + ); + }); + + it('rejects invalid body (missing content)', async () => { + harness.ctx._session.ralphTracker = { + addPlanTask: vi.fn(), + } as never; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task`, + payload: { priority: 'P1' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('accepts optional fields', async () => { + const addedTask = { + id: 'new-2', + content: 'Task with deps', + status: 'pending', + priority: 'P0', + dependencies: ['task-1'], + verificationCriteria: 'Tests pass', + }; + harness.ctx._session.ralphTracker = { + addPlanTask: vi.fn(() => ({ task: addedTask })), + } as never; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/plan/task`, + payload: { + content: 'Task with deps', + priority: 'P0', + verificationCriteria: 'Tests pass', + dependencies: ['task-1'], + insertAfter: 'task-0', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.dependencies).toEqual(['task-1']); + }); + }); +}); diff --git a/test/routes/push-routes.test.ts b/test/routes/push-routes.test.ts new file mode 100644 index 00000000..d135bd30 --- /dev/null +++ b/test/routes/push-routes.test.ts @@ -0,0 +1,196 @@ +/** + * @fileoverview Tests for push-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerPushRoutes } from '../../src/web/routes/push-routes.js'; + +describe('push-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerPushRoutes); + // Push routes expect ctx.pushStore to be a real object (not null) + harness.ctx.pushStore = { + getPublicKey: vi.fn(() => 'test-vapid-public-key-base64'), + addSubscription: vi.fn((record: Record) => record), + updatePreferences: vi.fn(() => true), + removeSubscription: vi.fn(() => true), + } as never; + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/push/vapid-key ========== + + describe('GET /api/push/vapid-key', () => { + it('returns the VAPID public key', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/push/vapid-key', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.publicKey).toBe('test-vapid-public-key-base64'); + }); + }); + + // ========== POST /api/push/subscribe ========== + + describe('POST /api/push/subscribe', () => { + it('creates a push subscription', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/push/subscribe', + payload: { + endpoint: 'https://push.example.com/send/abc123', + keys: { + p256dh: 'test-p256dh-key', + auth: 'test-auth-key', + }, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.id).toBeDefined(); + expect(harness.ctx.pushStore.addSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: 'https://push.example.com/send/abc123', + keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' }, + }), + ); + }); + + it('accepts optional userAgent and pushPreferences', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/push/subscribe', + payload: { + endpoint: 'https://push.example.com/send/abc123', + keys: { p256dh: 'test-p256dh', auth: 'test-auth' }, + userAgent: 'TestBrowser/1.0', + pushPreferences: { 'session:idle': true, 'session:error': false }, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.pushStore.addSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + userAgent: 'TestBrowser/1.0', + pushPreferences: { 'session:idle': true, 'session:error': false }, + }), + ); + }); + + it('rejects invalid subscription (missing endpoint)', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/push/subscribe', + payload: { + keys: { p256dh: 'test-p256dh', auth: 'test-auth' }, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects invalid subscription (missing keys)', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/push/subscribe', + payload: { + endpoint: 'https://push.example.com/send/abc123', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== PUT /api/push/subscribe/:id ========== + + describe('PUT /api/push/subscribe/:id', () => { + it('updates push preferences for a subscription', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/push/subscribe/sub-123', + payload: { + pushPreferences: { 'session:idle': true, 'session:error': true }, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.pushStore.updatePreferences).toHaveBeenCalledWith('sub-123', { + 'session:idle': true, + 'session:error': true, + }); + }); + + it('returns 404 for unknown subscription', async () => { + (harness.ctx.pushStore.updatePreferences as ReturnType).mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/push/subscribe/nonexistent', + payload: { + pushPreferences: { 'session:idle': true }, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + + it('rejects invalid body (missing pushPreferences)', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/push/subscribe/sub-123', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== DELETE /api/push/subscribe/:id ========== + + describe('DELETE /api/push/subscribe/:id', () => { + it('removes a push subscription', async () => { + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/push/subscribe/sub-123', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.pushStore.removeSubscription).toHaveBeenCalledWith('sub-123'); + }); + + it('returns 404 for unknown subscription', async () => { + (harness.ctx.pushStore.removeSubscription as ReturnType).mockReturnValue(false); + + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/push/subscribe/nonexistent', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toContain('not found'); + }); + }); +}); diff --git a/test/routes/ralph-routes.test.ts b/test/routes/ralph-routes.test.ts new file mode 100644 index 00000000..e0550b8e --- /dev/null +++ b/test/routes/ralph-routes.test.ts @@ -0,0 +1,474 @@ +/** + * @fileoverview Tests for ralph-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js'; + +/** Create a mock ralph tracker with all methods used by ralph-routes */ +function createMockRalphTracker() { + return { + enabled: false, + enable: vi.fn(), + disable: vi.fn(), + reset: vi.fn(), + fullReset: vi.fn(), + enableAutoEnable: vi.fn(), + disableAutoEnable: vi.fn(), + startLoop: vi.fn(), + setMaxIterations: vi.fn(), + resetCircuitBreaker: vi.fn(), + generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] Task 1\n'), + importFixPlanMarkdown: vi.fn(() => 3), + lastStatusBlock: null, + circuitBreakerStatus: { state: 'CLOSED', consecutiveFailures: 0 }, + cumulativeStats: { totalIterations: 0, totalSuccess: 0 }, + exitGateMet: false, + todos: [ + { id: '1', content: 'Task 1', done: false }, + { id: '2', content: 'Task 2', done: true }, + ], + }; +} + +describe('ralph-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerRalphRoutes); + // Add updateRalphEnabled to mock mux (used by ralph-config route) + (harness.ctx.mux as Record).updateRalphEnabled = vi.fn(); + // Set up mock ralph tracker on the session + (harness.ctx._session as Record).ralphTracker = createMockRalphTracker(); + (harness.ctx._session as Record).ralphLoopState = { enabled: false }; + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== POST /api/sessions/:id/ralph-config ========== + + describe('POST /api/sessions/:id/ralph-config', () => { + it('returns success for valid config', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: true }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + + it('enables ralph tracker', async () => { + const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: true }, + }); + expect(res.statusCode).toBe(200); + expect(tracker.enable).toHaveBeenCalled(); + expect(tracker.enableAutoEnable).toHaveBeenCalled(); + }); + + it('disables ralph tracker', async () => { + const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: false }, + }); + expect(res.statusCode).toBe(200); + expect(tracker.disable).toHaveBeenCalled(); + expect(tracker.disableAutoEnable).toHaveBeenCalled(); + }); + + it('persists ralph enabled state via mux', async () => { + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: true }, + }); + expect((harness.ctx.mux as Record).updateRalphEnabled).toHaveBeenCalledWith( + harness.ctx._sessionId, + true, + ); + }); + + it('broadcasts ralph loop update', async () => { + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: true }, + }); + expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:ralphLoopUpdate', { + sessionId: harness.ctx._sessionId, + state: expect.anything(), + }); + }); + + it('handles reset option', async () => { + const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { reset: true }, + }); + expect(tracker.reset).toHaveBeenCalled(); + }); + + it('configures completion phrase and max iterations', async () => { + const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { completionPhrase: 'DONE', maxIterations: 10 }, + }); + expect(tracker.startLoop).toHaveBeenCalledWith('DONE', 10); + }); + + it('sets max iterations independently', async () => { + const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { maxIterations: 50 }, + }); + expect(tracker.setMaxIterations).toHaveBeenCalledWith(50); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/ralph-config', + payload: { enabled: true }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects opencode sessions', async () => { + harness.ctx._session.mode = 'opencode'; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: true }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects invalid request body', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: 'not-boolean' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('handles disableAutoEnable flag', async () => { + const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { disableAutoEnable: true }, + }); + expect(tracker.disableAutoEnable).toHaveBeenCalled(); + }); + + it('persists session state after config change', async () => { + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`, + payload: { enabled: true }, + }); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + }); + }); + + // ========== POST /api/sessions/:id/ralph-circuit-breaker/reset ========== + + describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => { + it('resets circuit breaker for valid session', async () => { + const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-circuit-breaker/reset`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(tracker.resetCircuitBreaker).toHaveBeenCalled(); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/sessions/:id/ralph-status ========== + + describe('GET /api/sessions/:id/ralph-status', () => { + it('returns ralph status data', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-status`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toBeDefined(); + expect(body.data.circuitBreaker).toBeDefined(); + expect(body.data.cumulativeStats).toBeDefined(); + expect(body.data.exitGateMet).toBe(false); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/ralph-status', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/sessions/:id/fix-plan ========== + + describe('GET /api/sessions/:id/fix-plan', () => { + it('returns generated fix plan markdown', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/fix-plan`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.content).toContain('# Fix Plan'); + expect(body.data.todoCount).toBe(2); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/fix-plan', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/fix-plan/import ========== + + describe('POST /api/sessions/:id/fix-plan/import', () => { + it('imports fix plan content', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`, + payload: { content: '# Plan\n\n- [ ] Task A\n- [ ] Task B\n' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.importedCount).toBe(3); + }); + + it('persists session state after import', async () => { + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`, + payload: { content: '- [ ] Task' }, + }); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/fix-plan/import', + payload: { content: 'test' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects missing content field', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`, + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/ralph-prompt/write ========== + + describe('POST /api/sessions/:id/ralph-prompt/write', () => { + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/ralph-prompt/write', + payload: { content: 'test prompt' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns error when session has no working directory', async () => { + harness.ctx._session.workingDir = ''; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`, + payload: { content: 'test prompt' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects missing content field', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`, + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/fix-plan/write ========== + + describe('POST /api/sessions/:id/fix-plan/write', () => { + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/fix-plan/write', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns error when session has no working directory', async () => { + harness.ctx._session.workingDir = ''; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/write`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/fix-plan/read ========== + + describe('POST /api/sessions/:id/fix-plan/read', () => { + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/fix-plan/read', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns error when session has no working directory', async () => { + harness.ctx._session.workingDir = ''; + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/read`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/ralph-loop/start ========== + + describe('POST /api/ralph-loop/start', () => { + it('rejects invalid request body', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/ralph-loop/start', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects when max sessions reached', async () => { + // Fill up sessions to max + for (let i = 0; i < 50; i++) { + harness.ctx.sessions.set(`session-${i}`, {} as never); + } + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/ralph-loop/start', + payload: { + taskDescription: 'test task', + completionPhrase: 'DONE', + caseName: 'testcase', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects invalid case name format', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/ralph-loop/start', + payload: { + taskDescription: 'test task', + caseName: '../escape-path', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); +}); diff --git a/test/routes/scheduled-routes.test.ts b/test/routes/scheduled-routes.test.ts new file mode 100644 index 00000000..76e56d65 --- /dev/null +++ b/test/routes/scheduled-routes.test.ts @@ -0,0 +1,263 @@ +/** + * @fileoverview Tests for scheduled-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerScheduledRoutes } from '../../src/web/routes/scheduled-routes.js'; + +describe('scheduled-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerScheduledRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/scheduled ========== + + describe('GET /api/scheduled', () => { + it('returns empty array when no scheduled runs exist', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/scheduled', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toEqual([]); + }); + + it('returns all scheduled runs', async () => { + const run1 = { + id: 'run-1', + prompt: 'test prompt 1', + workingDir: '/tmp/test', + durationMinutes: 60, + startedAt: Date.now(), + endAt: Date.now() + 3600000, + status: 'running' as const, + sessionId: null, + completedTasks: 0, + totalCost: 0, + logs: [], + }; + const run2 = { + id: 'run-2', + prompt: 'test prompt 2', + workingDir: '/tmp/test2', + durationMinutes: 120, + startedAt: Date.now(), + endAt: Date.now() + 7200000, + status: 'completed' as const, + sessionId: 'sess-1', + completedTasks: 5, + totalCost: 1.5, + logs: ['started'], + }; + harness.ctx.scheduledRuns.set('run-1', run1); + harness.ctx.scheduledRuns.set('run-2', run2); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/scheduled', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toHaveLength(2); + }); + }); + + // ========== POST /api/scheduled ========== + + describe('POST /api/scheduled', () => { + it('creates a scheduled run with valid input', async () => { + const mockRun = { + id: 'new-run', + prompt: 'do something', + workingDir: '/tmp', + durationMinutes: 60, + startedAt: Date.now(), + endAt: Date.now() + 3600000, + status: 'running' as const, + sessionId: null, + completedTasks: 0, + totalCost: 0, + logs: [], + }; + harness.ctx.startScheduledRun = vi.fn(async () => mockRun); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/scheduled', + payload: { + prompt: 'do something', + durationMinutes: 60, + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.run.id).toBe('new-run'); + }); + + it('rejects empty prompt', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/scheduled', + payload: { + prompt: '', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects missing prompt', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/scheduled', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects invalid workingDir with shell metacharacters', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/scheduled', + payload: { + prompt: 'test prompt', + workingDir: '/tmp/test;rm -rf /', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('accepts optional durationMinutes', async () => { + const mockRun = { + id: 'run-default-duration', + prompt: 'test', + workingDir: process.cwd(), + durationMinutes: 60, + startedAt: Date.now(), + endAt: Date.now() + 3600000, + status: 'running' as const, + sessionId: null, + completedTasks: 0, + totalCost: 0, + logs: [], + }; + harness.ctx.startScheduledRun = vi.fn(async () => mockRun); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/scheduled', + payload: { + prompt: 'test', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + // Should default to 60 minutes + expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith( + 'test', + expect.any(String), + 60, + ); + }); + }); + + // ========== DELETE /api/scheduled/:id ========== + + describe('DELETE /api/scheduled/:id', () => { + it('deletes an existing scheduled run', async () => { + const run = { + id: 'run-to-delete', + prompt: 'test', + workingDir: '/tmp', + durationMinutes: 60, + startedAt: Date.now(), + endAt: Date.now() + 3600000, + status: 'running' as const, + sessionId: null, + completedTasks: 0, + totalCost: 0, + logs: [], + }; + harness.ctx.scheduledRuns.set('run-to-delete', run); + + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/scheduled/run-to-delete', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.stopScheduledRun).toHaveBeenCalledWith('run-to-delete'); + }); + + it('returns error for nonexistent scheduled run', async () => { + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/scheduled/nonexistent', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toBeDefined(); + }); + }); + + // ========== GET /api/scheduled/:id ========== + + describe('GET /api/scheduled/:id', () => { + it('returns an existing scheduled run', async () => { + const run = { + id: 'run-1', + prompt: 'test prompt', + workingDir: '/tmp/test', + durationMinutes: 60, + startedAt: Date.now(), + endAt: Date.now() + 3600000, + status: 'running' as const, + sessionId: null, + completedTasks: 0, + totalCost: 0, + logs: [], + }; + harness.ctx.scheduledRuns.set('run-1', run); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/scheduled/run-1', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.id).toBe('run-1'); + expect(body.prompt).toBe('test prompt'); + }); + + it('returns error for nonexistent scheduled run', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/scheduled/nonexistent', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toBeDefined(); + }); + }); +}); diff --git a/test/routes/team-routes.test.ts b/test/routes/team-routes.test.ts new file mode 100644 index 00000000..e39162cd --- /dev/null +++ b/test/routes/team-routes.test.ts @@ -0,0 +1,91 @@ +/** + * @fileoverview Tests for team-routes route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerTeamRoutes } from '../../src/web/routes/team-routes.js'; + +describe('team-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerTeamRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/teams ========== + + describe('GET /api/teams', () => { + it('returns empty array when no teams exist', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/teams', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toEqual([]); + }); + + it('returns teams from teamWatcher', async () => { + const mockTeams = [ + { name: 'team-alpha', leadSessionId: 'session-1', members: [{ name: 'lead', agentType: 'team-lead' }] }, + { name: 'team-beta', leadSessionId: 'session-2', members: [] }, + ]; + harness.ctx.teamWatcher.getTeams.mockReturnValue(mockTeams); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/teams', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveLength(2); + expect(body.data[0].name).toBe('team-alpha'); + expect(body.data[1].name).toBe('team-beta'); + }); + }); + + // ========== GET /api/teams/:name/tasks ========== + + describe('GET /api/teams/:name/tasks', () => { + it('returns empty array for unknown team', async () => { + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/teams/nonexistent/tasks', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toEqual([]); + }); + + it('returns tasks for a team', async () => { + const mockTasks = [ + { id: '1', subject: 'Implement feature', status: 'in_progress', owner: 'dev-1' }, + { id: '2', subject: 'Write tests', status: 'pending', owner: null }, + ]; + harness.ctx.teamWatcher.getTeamTasks.mockReturnValue(mockTasks); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/teams/team-alpha/tasks', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveLength(2); + expect(body.data[0].subject).toBe('Implement feature'); + expect(body.data[1].status).toBe('pending'); + }); + }); +});