chore: version packages

This commit is contained in:
arkon
2026-03-01 17:34:09 +01:00
parent 18217268bf
commit b79ad497f4
23 changed files with 3510 additions and 6 deletions
+6
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -4
View File
@@ -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
+67
View File
@@ -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();
+85
View File
@@ -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');
+48
View File
@@ -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();
+87
View File
@@ -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');
+116
View File
@@ -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');
+114
View File
@@ -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');
+109
View File
@@ -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');
+69
View File
@@ -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');
+69
View File
@@ -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');
+61
View File
@@ -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');
+515
View File
@@ -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<typeof import('node:fs')>();
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');
});
});
});
+365
View File
@@ -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<typeof import('node:fs')>();
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);
});
});
});
+221
View File
@@ -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);
});
});
});
+141
View File
@@ -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();
});
});
});
+410
View File
@@ -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']);
});
});
});
+196
View File
@@ -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<string, unknown>) => 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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
});
+474
View File
@@ -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<string, unknown>).updateRalphEnabled = vi.fn();
// Set up mock ralph tracker on the session
(harness.ctx._session as Record<string, unknown>).ralphTracker = createMockRalphTracker();
(harness.ctx._session as Record<string, unknown>).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<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
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<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
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<string, unknown>).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<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
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<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
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<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
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<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
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<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
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);
});
});
});
+263
View File
@@ -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();
});
});
});
+91
View File
@@ -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');
});
});
});