mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
chore: version packages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user