fix: Ralph wizard uses file-based prompt to avoid screen escaping issues

- Add /api/sessions/:id/ralph-prompt/write endpoint to write prompt to @ralph_prompt.md
- Wizard now writes full prompt to file, then sends simple read command to Claude
- Fix session readiness check to wait for prompt character instead of notWorking flag
- Add E2E test infrastructure for ralph-loop workflow (port 3190)
- Add ralph-wizard-prod.mjs script for production testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-27 17:46:43 +01:00
co-authored by Claude Opus 4.5
parent c4e5337480
commit 2e4d89496f
27 changed files with 3960 additions and 32 deletions
+149
View File
@@ -0,0 +1,149 @@
/**
* Browser fixture for E2E tests
* Manages Playwright browser lifecycle with required args for headless Chrome
*/
import { chromium, Browser, BrowserContext, Page } from 'playwright';
export interface BrowserFixture {
browser: Browser;
context: BrowserContext;
page: Page;
}
/**
* Create and launch a browser fixture
* Uses required args for headless Chrome in CI/Linux environments
* @returns BrowserFixture with browser, context, and page
*/
export async function createBrowserFixture(): Promise<BrowserFixture> {
const browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
],
});
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
});
const page = await context.newPage();
// Set default timeout for all operations
page.setDefaultTimeout(15000);
return {
browser,
context,
page,
};
}
/**
* Close and cleanup a browser fixture
* @param fixture - Browser fixture to destroy
*/
export async function destroyBrowserFixture(fixture: BrowserFixture): Promise<void> {
if (fixture.page) {
await fixture.page.close().catch(() => {});
}
if (fixture.context) {
await fixture.context.close().catch(() => {});
}
if (fixture.browser) {
await fixture.browser.close().catch(() => {});
}
}
/**
* Navigate to a URL and wait for load
* Note: Uses 'domcontentloaded' because SSE streams prevent 'networkidle'
* @param page - Playwright page
* @param url - URL to navigate to
*/
export async function navigateTo(page: Page, url: string): Promise<void> {
await page.goto(url, { waitUntil: 'domcontentloaded' });
// Wait a bit for JS to initialize
await page.waitForTimeout(500);
}
/**
* Wait for an element to be visible
* @param page - Playwright page
* @param selector - CSS selector
* @param timeout - Timeout in ms (default 15000)
*/
export async function waitForVisible(page: Page, selector: string, timeout = 15000): Promise<void> {
await page.waitForSelector(selector, { state: 'visible', timeout });
}
/**
* Click an element with retry logic
* @param page - Playwright page
* @param selector - CSS selector
*/
export async function clickElement(page: Page, selector: string): Promise<void> {
await page.click(selector);
}
/**
* Type text into an input element
* @param page - Playwright page
* @param selector - CSS selector
* @param text - Text to type
*/
export async function typeInto(page: Page, selector: string, text: string): Promise<void> {
await page.fill(selector, text);
}
/**
* Get text content of an element
* @param page - Playwright page
* @param selector - CSS selector
* @returns Text content or empty string
*/
export async function getText(page: Page, selector: string): Promise<string> {
try {
const element = await page.$(selector);
if (element) {
return (await element.textContent()) || '';
}
return '';
} catch {
return '';
}
}
/**
* Check if an element is visible
* @param page - Playwright page
* @param selector - CSS selector
* @returns true if visible, false otherwise
*/
export async function isVisible(page: Page, selector: string): Promise<boolean> {
try {
const element = await page.$(selector);
if (element) {
return await element.isVisible();
}
return false;
} catch {
return false;
}
}
/**
* Get count of elements matching selector
* @param page - Playwright page
* @param selector - CSS selector
* @returns Number of matching elements
*/
export async function getElementCount(page: Page, selector: string): Promise<number> {
return await page.locator(selector).count();
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Cleanup fixture for E2E tests
* Tracks and cleans up all resources (sessions, cases, screens)
*/
import { execSync } from 'node:child_process';
import { existsSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
export class CleanupTracker {
private sessions: Set<string> = new Set();
private cases: Set<string> = new Set();
private screens: Set<string> = new Set();
private baseUrl: string;
private casesDir: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.casesDir = join(homedir(), 'claudeman-cases');
}
/**
* Track a session for cleanup
*/
trackSession(sessionId: string): void {
this.sessions.add(sessionId);
}
/**
* Track a case for cleanup
*/
trackCase(caseName: string): void {
this.cases.add(caseName);
}
/**
* Track a screen for cleanup
*/
trackScreen(screenName: string): void {
this.screens.add(screenName);
}
/**
* Clean up all tracked resources
*/
async cleanup(): Promise<void> {
// Delete sessions via API
for (const sessionId of this.sessions) {
try {
await fetch(`${this.baseUrl}/api/sessions/${sessionId}`, {
method: 'DELETE',
});
} catch {
// Ignore errors, session may already be deleted
}
}
this.sessions.clear();
// Delete case directories
for (const caseName of this.cases) {
try {
const casePath = join(this.casesDir, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
} catch {
// Ignore errors
}
}
this.cases.clear();
// Kill tracked screens
for (const screenName of this.screens) {
this.killScreen(screenName);
}
this.screens.clear();
}
/**
* Kill a specific screen session
*/
private killScreen(screenName: string): void {
try {
execSync(`screen -S ${screenName} -X quit 2>/dev/null`, {
stdio: 'pipe',
timeout: 5000,
});
} catch {
// Screen may not exist or already be dead
}
}
/**
* Force cleanup ALL e2e-test-* cases and claudeman-* screens
* Use this in afterAll to ensure no orphans remain
*/
async forceCleanupAll(): Promise<void> {
// First, clean tracked resources
await this.cleanup();
// Force kill ALL e2e-test sessions via API
try {
const response = await fetch(`${this.baseUrl}/api/sessions`);
if (response.ok) {
const data = await response.json();
if (data.sessions) {
for (const session of data.sessions) {
if (session.name?.startsWith('e2e-test-') || session.workingDir?.includes('e2e-test-')) {
try {
await fetch(`${this.baseUrl}/api/sessions/${session.id}`, {
method: 'DELETE',
});
} catch {
// Ignore
}
}
}
}
}
} catch {
// Server may be down
}
// Clean up e2e-test-* case directories
try {
if (existsSync(this.casesDir)) {
const entries = execSync(`ls -1 "${this.casesDir}" 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: 5000,
}).trim().split('\n').filter(Boolean);
for (const entry of entries) {
if (entry.startsWith('e2e-test-')) {
const casePath = join(this.casesDir, entry);
rmSync(casePath, { recursive: true, force: true });
}
}
}
} catch {
// Ignore cleanup errors
}
// Kill all e2e-test claudeman screens
try {
const screenList = execSync('screen -ls 2>/dev/null || true', {
encoding: 'utf-8',
timeout: 5000,
});
const screenLines = screenList.split('\n');
for (const line of screenLines) {
// Match claudeman-e2e-test-* screens
const match = line.match(/\d+\.(claudeman-e2e-test-[^\s]+)/);
if (match) {
const screenName = match[1];
this.killScreen(screenName);
}
}
} catch {
// Ignore screen cleanup errors
}
}
/**
* Get list of screens that match a pattern
*/
getScreensByPattern(pattern: string): string[] {
try {
const screenList = execSync('screen -ls 2>/dev/null || true', {
encoding: 'utf-8',
timeout: 5000,
});
const screens: string[] = [];
const screenLines = screenList.split('\n');
for (const line of screenLines) {
if (line.includes(pattern)) {
const match = line.match(/\d+\.([^\s]+)/);
if (match) {
screens.push(match[1]);
}
}
}
return screens;
} catch {
return [];
}
}
/**
* Check if a screen exists
*/
screenExists(screenName: string): boolean {
try {
const screenList = execSync('screen -ls 2>/dev/null || true', {
encoding: 'utf-8',
timeout: 5000,
});
return screenList.includes(screenName);
} catch {
return false;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* E2E Test Fixtures
* Central export for all fixture utilities
*/
export {
createServerFixture,
destroyServerFixture,
type ServerFixture,
} from './server.fixture.js';
export {
createBrowserFixture,
destroyBrowserFixture,
navigateTo,
waitForVisible,
clickElement,
typeInto,
getText,
isVisible,
getElementCount,
type BrowserFixture,
} from './browser.fixture.js';
export {
CleanupTracker,
} from './cleanup.fixture.js';
export {
captureAndCompare,
captureScreenshot,
updateBaseline,
type ScreenshotOptions,
type ScreenshotResult,
} from './screenshot.fixture.js';
+25
View File
@@ -0,0 +1,25 @@
/**
* Type declarations for pixelmatch
*/
declare module 'pixelmatch' {
interface PixelmatchOptions {
threshold?: number;
includeAA?: boolean;
alpha?: number;
aaColor?: [number, number, number];
diffColor?: [number, number, number];
diffColorAlt?: [number, number, number] | null;
diffMask?: boolean;
}
function pixelmatch(
img1: Uint8Array | Uint8ClampedArray | Buffer,
img2: Uint8Array | Uint8ClampedArray | Buffer,
output: Uint8Array | Uint8ClampedArray | Buffer | null,
width: number,
height: number,
options?: PixelmatchOptions
): number;
export = pixelmatch;
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Screenshot fixture for E2E tests
* Captures screenshots and compares against baselines for visual regression testing
*/
import { Page } from 'playwright';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCREENSHOTS_DIR = join(__dirname, '..', 'screenshots');
const BASELINES_DIR = join(SCREENSHOTS_DIR, 'baselines');
const CURRENT_DIR = join(SCREENSHOTS_DIR, 'current');
const DIFFS_DIR = join(SCREENSHOTS_DIR, 'diffs');
// Default threshold - percentage of pixels allowed to differ (0-1)
const DEFAULT_THRESHOLD = 0.05; // 5%
export interface ScreenshotOptions {
/** Threshold for pixel difference (0-1), default 0.05 */
threshold?: number;
/** Mask selectors to exclude from comparison */
mask?: string[];
/** Full page screenshot vs viewport only */
fullPage?: boolean;
}
export interface ScreenshotResult {
/** Whether comparison passed */
passed: boolean;
/** Path to baseline image */
baselinePath: string;
/** Path to current image */
currentPath: string;
/** Path to diff image (only if comparison failed) */
diffPath?: string;
/** Number of different pixels */
diffPixels?: number;
/** Percentage of different pixels */
diffPercent?: number;
/** Whether this is a new baseline (no previous baseline existed) */
newBaseline: boolean;
}
/**
* Ensure screenshot directories exist
*/
function ensureDirectories(): void {
for (const dir of [SCREENSHOTS_DIR, BASELINES_DIR, CURRENT_DIR, DIFFS_DIR]) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
}
/**
* Capture a screenshot and compare against baseline
* @param page - Playwright page
* @param name - Screenshot name (without extension)
* @param options - Screenshot options
* @returns ScreenshotResult with comparison details
*/
export async function captureAndCompare(
page: Page,
name: string,
options: ScreenshotOptions = {}
): Promise<ScreenshotResult> {
ensureDirectories();
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
const baselinePath = join(BASELINES_DIR, `${name}.png`);
const currentPath = join(CURRENT_DIR, `${name}.png`);
const diffPath = join(DIFFS_DIR, `${name}.png`);
// Mask elements if specified (hide dynamic content)
if (options.mask && options.mask.length > 0) {
for (const selector of options.mask) {
await page.evaluate((sel) => {
const elements = document.querySelectorAll(sel);
elements.forEach(el => {
(el as HTMLElement).style.visibility = 'hidden';
});
}, selector);
}
}
// Capture current screenshot
const screenshotBuffer = await page.screenshot({
path: currentPath,
fullPage: options.fullPage ?? false,
});
// Restore masked elements
if (options.mask && options.mask.length > 0) {
for (const selector of options.mask) {
await page.evaluate((sel) => {
const elements = document.querySelectorAll(sel);
elements.forEach(el => {
(el as HTMLElement).style.visibility = 'visible';
});
}, selector);
}
}
// If no baseline exists, create one
if (!existsSync(baselinePath)) {
writeFileSync(baselinePath, screenshotBuffer);
return {
passed: true,
baselinePath,
currentPath,
newBaseline: true,
};
}
// Load baseline and current images
const baselineBuffer = readFileSync(baselinePath);
const baselineImg = PNG.sync.read(baselineBuffer);
const currentImg = PNG.sync.read(screenshotBuffer);
// Check dimensions match
if (baselineImg.width !== currentImg.width || baselineImg.height !== currentImg.height) {
// Dimensions changed - save diff and fail
return {
passed: false,
baselinePath,
currentPath,
diffPath,
diffPixels: baselineImg.width * baselineImg.height,
diffPercent: 1,
newBaseline: false,
};
}
// Compare images
const { width, height } = baselineImg;
const diffImg = new PNG({ width, height });
const diffPixels = pixelmatch(
baselineImg.data,
currentImg.data,
diffImg.data,
width,
height,
{ threshold: 0.1 } // pixelmatch threshold (per-pixel sensitivity)
);
const totalPixels = width * height;
const diffPercent = diffPixels / totalPixels;
const passed = diffPercent <= threshold;
// Save diff image if comparison failed
if (!passed) {
writeFileSync(diffPath, PNG.sync.write(diffImg));
}
return {
passed,
baselinePath,
currentPath,
diffPath: passed ? undefined : diffPath,
diffPixels,
diffPercent,
newBaseline: false,
};
}
/**
* Capture a screenshot without comparison (for debugging)
* @param page - Playwright page
* @param name - Screenshot name
*/
export async function captureScreenshot(page: Page, name: string): Promise<string> {
ensureDirectories();
const path = join(CURRENT_DIR, `${name}.png`);
await page.screenshot({ path });
return path;
}
/**
* Update baseline with current screenshot
* @param name - Screenshot name
*/
export function updateBaseline(name: string): boolean {
const currentPath = join(CURRENT_DIR, `${name}.png`);
const baselinePath = join(BASELINES_DIR, `${name}.png`);
if (!existsSync(currentPath)) {
return false;
}
const currentBuffer = readFileSync(currentPath);
writeFileSync(baselinePath, currentBuffer);
return true;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Server fixture for E2E tests
* Manages WebServer lifecycle with proper startup/shutdown
*/
import { WebServer } from '../../../src/web/server.js';
export interface ServerFixture {
server: WebServer;
port: number;
baseUrl: string;
}
/**
* Create and start a server fixture
* @param port - Port to run the server on
* @returns ServerFixture with server instance and connection info
*/
export async function createServerFixture(port: number): Promise<ServerFixture> {
const server = new WebServer(port);
await server.start();
// Wait for server to be fully ready
const maxWait = 10000;
const start = Date.now();
while (Date.now() - start < maxWait) {
try {
const response = await fetch(`http://localhost:${port}/api/status`);
if (response.ok) {
break;
}
} catch {
// Server not ready yet
}
await new Promise(r => setTimeout(r, 100));
}
return {
server,
port,
baseUrl: `http://localhost:${port}`,
};
}
/**
* Stop and cleanup a server fixture
* @param fixture - Server fixture to destroy
*/
export async function destroyServerFixture(fixture: ServerFixture): Promise<void> {
if (fixture.server) {
await fixture.server.stop();
}
}