chore: bump version to 0.1443

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-30 22:25:59 +01:00
co-authored by Claude Opus 4.5
parent ce0139e393
commit 2a8882a94b
19 changed files with 1071 additions and 1606 deletions
File diff suppressed because it is too large Load Diff
+29
View File
@@ -14,6 +14,35 @@ export const E2E_PORTS = {
INPUT_INTERACTIONS: 3188,
RESPAWN_FLOW: 3189,
RALPH_LOOP: 3190,
MOBILE_SAFARI: 3191,
} as const;
// Mobile device viewports for responsive testing
export const MOBILE_VIEWPORTS = {
// iPhone 17 Pro - 402x874 @3x
IPHONE_17_PRO: {
width: 402,
height: 874,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
},
// iPhone 17 Pro Max - 440x956 @3x
IPHONE_17_PRO_MAX: {
width: 440,
height: 956,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
},
// iPad Pro 11" - 834x1194 @2x
IPAD_PRO_11: {
width: 834,
height: 1194,
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
},
} as const;
// Timeouts for various operations
+14
View File
@@ -22,6 +22,20 @@ export {
type BrowserFixture,
} from './browser.fixture.js';
export {
createMobileSafariFixture,
destroyMobileBrowserFixture,
tap,
swipe,
swipeHorizontal,
longPress,
getComputedStyle,
hasBodyClass,
getMinHeight,
type MobileBrowserFixture,
type MobileViewport,
} from './mobile-browser.fixture.js';
export {
CleanupTracker,
} from './cleanup.fixture.js';
+204
View File
@@ -0,0 +1,204 @@
/**
* Mobile browser fixture for E2E tests
* Manages Playwright WebKit browser lifecycle with mobile viewport emulation
*/
import { webkit, Browser, BrowserContext, Page } from 'playwright';
import { MOBILE_VIEWPORTS } from '../e2e.config.js';
export interface MobileBrowserFixture {
browser: Browser;
context: BrowserContext;
page: Page;
}
export interface MobileViewport {
width: number;
height: number;
deviceScaleFactor: number;
isMobile: boolean;
hasTouch: boolean;
}
/**
* Create and launch a mobile Safari (WebKit) browser fixture
* Defaults to iPhone 17 Pro viewport
* @param viewport - Optional viewport configuration (defaults to iPhone 17 Pro)
* @returns MobileBrowserFixture with browser, context, and page
*/
export async function createMobileSafariFixture(
viewport: MobileViewport = MOBILE_VIEWPORTS.IPHONE_17_PRO
): Promise<MobileBrowserFixture> {
const browser = await webkit.launch({
headless: true,
});
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: viewport.deviceScaleFactor,
isMobile: viewport.isMobile,
hasTouch: viewport.hasTouch,
ignoreHTTPSErrors: true,
// Safari-like user agent
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
});
const page = await context.newPage();
// Set default timeout for all operations
page.setDefaultTimeout(15000);
return {
browser,
context,
page,
};
}
/**
* Close and cleanup a mobile browser fixture
* @param fixture - Mobile browser fixture to destroy
*/
export async function destroyMobileBrowserFixture(fixture: MobileBrowserFixture): 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(() => {});
}
}
/**
* Simulate a tap gesture (touch start + touch end)
* @param page - Playwright page
* @param selector - CSS selector or x,y coordinates
*/
export async function tap(page: Page, selectorOrCoords: string | { x: number; y: number }): Promise<void> {
if (typeof selectorOrCoords === 'string') {
await page.tap(selectorOrCoords);
} else {
await page.touchscreen.tap(selectorOrCoords.x, selectorOrCoords.y);
}
}
/**
* Simulate a swipe gesture
* @param page - Playwright page
* @param startX - Starting X coordinate
* @param startY - Starting Y coordinate
* @param endX - Ending X coordinate
* @param endY - Ending Y coordinate
* @param duration - Duration in ms (default 100ms for fast swipe)
*/
export async function swipe(
page: Page,
startX: number,
startY: number,
endX: number,
endY: number,
duration = 100
): Promise<void> {
// Calculate steps based on duration (roughly 16ms per step for 60fps)
const steps = Math.max(Math.floor(duration / 16), 2);
// Start touch
await page.touchscreen.tap(startX, startY);
// Move through intermediate points
for (let i = 1; i <= steps; i++) {
const ratio = i / steps;
const x = startX + (endX - startX) * ratio;
const y = startY + (endY - startY) * ratio;
await page.touchscreen.tap(x, y);
}
}
/**
* Simulate a horizontal swipe (for tab switching)
* @param page - Playwright page
* @param direction - 'left' or 'right'
* @param distance - Swipe distance in pixels (default 100)
*/
export async function swipeHorizontal(
page: Page,
direction: 'left' | 'right',
distance = 100
): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) return;
const centerX = viewport.width / 2;
const centerY = viewport.height / 2;
if (direction === 'left') {
await swipe(page, centerX + distance / 2, centerY, centerX - distance / 2, centerY);
} else {
await swipe(page, centerX - distance / 2, centerY, centerX + distance / 2, centerY);
}
}
/**
* Simulate a long press gesture
* @param page - Playwright page
* @param selector - CSS selector
* @param duration - Hold duration in ms (default 500ms)
*/
export async function longPress(page: Page, selector: string, duration = 500): Promise<void> {
const element = await page.$(selector);
if (!element) throw new Error(`Element not found: ${selector}`);
const box = await element.boundingBox();
if (!box) throw new Error(`Element has no bounding box: ${selector}`);
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
// Simulate long press with mouse (touch events are more complex)
await page.mouse.move(x, y);
await page.mouse.down();
await page.waitForTimeout(duration);
await page.mouse.up();
}
/**
* Get computed style property for an element
* @param page - Playwright page
* @param selector - CSS selector
* @param property - CSS property name
* @returns The computed style value
*/
export async function getComputedStyle(
page: Page,
selector: string,
property: string
): Promise<string> {
return await page.$eval(
selector,
(el, prop) => window.getComputedStyle(el).getPropertyValue(prop),
property
);
}
/**
* Check if body has a specific class
* @param page - Playwright page
* @param className - Class name to check
* @returns true if class exists
*/
export async function hasBodyClass(page: Page, className: string): Promise<boolean> {
return await page.evaluate((cls) => document.body.classList.contains(cls), className);
}
/**
* Get the minimum height of an element
* @param page - Playwright page
* @param selector - CSS selector
* @returns Minimum height in pixels
*/
export async function getMinHeight(page: Page, selector: string): Promise<number> {
const minHeightStr = await getComputedStyle(page, selector, 'min-height');
return parseInt(minHeightStr) || 0;
}
+4 -4
View File
@@ -106,7 +106,7 @@ describe('Agent Interactions E2E', () => {
// Track session
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -158,7 +158,7 @@ describe('Agent Interactions E2E', () => {
// Track session
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
}
// Test passed - either tested minimize or session created successfully
@@ -232,7 +232,7 @@ describe('Agent Interactions E2E', () => {
// Track sessions
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -267,7 +267,7 @@ describe('Agent Interactions E2E', () => {
// Get session ID
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
const sessionId = data.sessions?.[data.sessions.length - 1]?.id;
const sessionId = data?.[data.length - 1]?.id;
// Count windows before deletion
const windowsBefore = await getElementCount(page, '.subagent-window');
+3 -3
View File
@@ -267,7 +267,7 @@ describe('Input Interactions E2E', () => {
// Track session
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -361,7 +361,7 @@ describe('Input Interactions E2E', () => {
// Track session
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -408,7 +408,7 @@ describe('Input Interactions E2E', () => {
// Track session
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
+265
View File
@@ -0,0 +1,265 @@
/**
* Mobile Safari E2E Tests
* Tests mobile-specific UI adaptations on iPhone 17 Pro Safari (402x874, 3x DPR)
*
* Port: 3191 (see CLAUDE.md test port table)
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import {
createServerFixture,
destroyServerFixture,
createMobileSafariFixture,
destroyMobileBrowserFixture,
hasBodyClass,
getMinHeight,
tap,
CleanupTracker,
type ServerFixture,
type MobileBrowserFixture,
} from '../fixtures/index.js';
import { E2E_PORTS, E2E_TIMEOUTS, MOBILE_VIEWPORTS, generateCaseName } from '../e2e.config.js';
const PORT = E2E_PORTS.MOBILE_SAFARI;
let serverFixture: ServerFixture | null = null;
let cleanup: CleanupTracker;
describe('Mobile Safari E2E', () => {
beforeAll(async () => {
serverFixture = await createServerFixture(PORT);
cleanup = new CleanupTracker(serverFixture.baseUrl);
}, E2E_TIMEOUTS.BROWSER_SETUP);
afterAll(async () => {
if (cleanup) {
await cleanup.forceCleanupAll();
}
if (serverFixture) {
await destroyServerFixture(serverFixture);
}
}, E2E_TIMEOUTS.TEST);
it('should render touch-friendly UI on mobile viewport', async () => {
let browser: MobileBrowserFixture | null = null;
try {
// Launch WebKit with iPhone 17 Pro viewport
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
// Navigate to Claudeman
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000); // Wait for JS initialization and MobileDetection
// Verify page loaded
const title = await page.title();
expect(title).toBe('Claudeman');
// Check body has touch-device class
const hasTouchClass = await hasBodyClass(page, 'touch-device');
expect(hasTouchClass).toBe(true);
// Check body has device-mobile class (width < 430px)
const hasMobileClass = await hasBodyClass(page, 'device-mobile');
expect(hasMobileClass).toBe(true);
// Verify viewport size is correct
const viewport = page.viewportSize();
expect(viewport?.width).toBe(402);
expect(viewport?.height).toBe(874);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should have 44px minimum touch targets on buttons', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Check button min-height is at least 44px (touch target minimum)
// The .btn-toolbar.btn-claude is the main Run Claude button
const claudeButtonMinHeight = await getMinHeight(page, '.btn-toolbar.btn-claude');
expect(claudeButtonMinHeight).toBeGreaterThanOrEqual(44);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should handle tap gestures for session creation', async () => {
let browser: MobileBrowserFixture | null = null;
const caseName = generateCaseName('mobile-tap');
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
cleanup.trackCase(caseName);
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Create case via API
const createRes = await fetch(`${serverFixture!.baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
expect(createRes.ok).toBe(true);
// Refresh to get case list
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
// Select case
await page.selectOption('#quickStartCase', caseName);
// Use tap instead of click for the Claude button
await tap(page, '.btn-toolbar.btn-claude');
// Wait for session tab to appear
await page.waitForSelector('.session-tab', { timeout: E2E_TIMEOUTS.SESSION_CREATE });
// Verify tab is visible
const tabVisible = await page.isVisible('.session-tab.active');
expect(tabVisible).toBe(true);
// Track session for cleanup
const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`);
const data = await response.json();
const session = data?.find((s: any) => s.workingDir?.includes(caseName));
if (session) {
cleanup.trackSession(session.id);
}
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should show always-visible close buttons on session tabs', async () => {
let browser: MobileBrowserFixture | null = null;
const caseName = generateCaseName('mobile-close-btn');
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
cleanup.trackCase(caseName);
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Create case and session
await fetch(`${serverFixture!.baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.selectOption('#quickStartCase', caseName);
await tap(page, '.btn-toolbar.btn-claude');
// Wait for session tab
await page.waitForSelector('.session-tab', { timeout: E2E_TIMEOUTS.SESSION_CREATE });
// On touch devices, close button should always be visible (opacity: 1)
// Check that the close button exists and is visible
const closeButtonVisible = await page.isVisible('.session-tab .tab-close');
expect(closeButtonVisible).toBe(true);
// Verify opacity is 1 (always visible, not hover-dependent)
const opacity = await page.$eval('.session-tab .tab-close', (el) =>
window.getComputedStyle(el).opacity
);
expect(parseFloat(opacity)).toBe(1);
// Track for cleanup
const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`);
const data = await response.json();
if (data?.length > 0) {
cleanup.trackSession(data[data.length - 1].id);
}
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should hide header brand and stats on small screens', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Header brand should be hidden on phones (<430px)
const brandVisible = await page.isVisible('.header-brand');
expect(brandVisible).toBe(false);
// System stats should be hidden on phones
const statsVisible = await page.isVisible('.header-system-stats');
expect(statsVisible).toBe(false);
// Font controls should be hidden on phones
const fontControlsVisible = await page.isVisible('.header-font-controls');
expect(fontControlsVisible).toBe(false);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should render properly on tablet viewport', async () => {
let browser: MobileBrowserFixture | null = null;
try {
// Use iPad Pro 11" viewport (tablet size)
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPAD_PRO_11);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Check body has tablet class (width 430-768px... wait, iPad is 834px wide, so it's desktop)
// Actually iPad Pro 11" at 834px is > 768px so it would be device-desktop
// Let's check what class it gets
const hasDesktopClass = await hasBodyClass(page, 'device-desktop');
expect(hasDesktopClass).toBe(true);
// But it should still have touch-device class
const hasTouchClass = await hasBodyClass(page, 'touch-device');
expect(hasTouchClass).toBe(true);
// Verify viewport
const viewport = page.viewportSize();
expect(viewport?.width).toBe(834);
expect(viewport?.height).toBe(1194);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
+4 -4
View File
@@ -101,7 +101,7 @@ describe('Multi-Session E2E', () => {
// Track sessions for cleanup
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -175,7 +175,7 @@ describe('Multi-Session E2E', () => {
// Track sessions
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -249,7 +249,7 @@ describe('Multi-Session E2E', () => {
// Track sessions
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -298,7 +298,7 @@ describe('Multi-Session E2E', () => {
// Track all sessions
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
// Delete middle session
await clickElement(page, '.session-tab:nth-child(2)');
+5 -5
View File
@@ -94,7 +94,7 @@ describe('Quick Start E2E', () => {
// Get session ID from API
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
const session = data.sessions?.find((s: any) => s.workingDir?.includes(caseName));
const session = data?.find((s: any) => s.workingDir?.includes(caseName));
expect(session).toBeDefined();
cleanup.trackSession(session.id);
@@ -155,9 +155,9 @@ describe('Quick Start E2E', () => {
// Get session from API
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
expect(data.sessions?.length).toBeGreaterThan(0);
expect(data?.length).toBeGreaterThan(0);
const session = data.sessions.find((s: any) => s.workingDir?.includes(caseName));
const session = data.find((s: any) => s.workingDir?.includes(caseName));
expect(session).toBeDefined();
cleanup.trackSession(session.id);
@@ -210,8 +210,8 @@ describe('Quick Start E2E', () => {
// Track for cleanup
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
if (data.sessions?.length > 0) {
cleanup.trackSession(data.sessions[data.sessions.length - 1].id);
if (data?.length > 0) {
cleanup.trackSession(data[data.length - 1].id);
}
} finally {
+5 -5
View File
@@ -93,7 +93,7 @@ describe('Respawn Flow E2E', () => {
// Track session
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
data.sessions?.forEach((s: any) => cleanup.trackSession(s.id));
data?.forEach((s: any) => cleanup.trackSession(s.id));
} finally {
if (browser) {
@@ -128,7 +128,7 @@ describe('Respawn Flow E2E', () => {
// Get session ID
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
const session = data.sessions?.[data.sessions.length - 1];
const session = data?.[data.length - 1];
cleanup.trackSession(session.id);
// Enable respawn via API
@@ -181,7 +181,7 @@ describe('Respawn Flow E2E', () => {
// Get session ID
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
const session = data.sessions?.[data.sessions.length - 1];
const session = data?.[data.length - 1];
cleanup.trackSession(session.id);
// Enable respawn first
@@ -256,7 +256,7 @@ describe('Respawn Flow E2E', () => {
// Get session ID
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
const session = data.sessions?.[data.sessions.length - 1];
const session = data?.[data.length - 1];
cleanup.trackSession(session.id);
// Enable and start respawn
@@ -318,7 +318,7 @@ describe('Respawn Flow E2E', () => {
// Get session ID
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
const session = data.sessions?.[data.sessions.length - 1];
const session = data?.[data.length - 1];
cleanup.trackSession(session.id);
// Configure auto-compact
+6 -6
View File
@@ -73,8 +73,8 @@ describe('Session Delete E2E', () => {
// Get session ID
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
expect(data.sessions?.length).toBeGreaterThan(0);
const session = data.sessions.find((s: any) => s.workingDir?.includes(caseName));
expect(data?.length).toBeGreaterThan(0);
const session = data.find((s: any) => s.workingDir?.includes(caseName));
expect(session).toBeDefined();
const sessionId = session.id;
@@ -103,7 +103,7 @@ describe('Session Delete E2E', () => {
// Verify API returns no sessions with that ID
const afterResponse = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const afterData = await afterResponse.json();
const remainingSessions = afterData.sessions?.filter((s: any) => s.id === sessionId);
const remainingSessions = afterData?.filter((s: any) => s.id === sessionId);
expect(remainingSessions?.length || 0).toBe(0);
} finally {
@@ -166,7 +166,7 @@ describe('Session Delete E2E', () => {
// Get middle session ID
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
const sessions = data.sessions || [];
const sessions = data || [];
sessions.forEach((s: any) => cleanup.trackSession(s.id));
// Click middle tab to make it active
@@ -227,8 +227,8 @@ describe('Session Delete E2E', () => {
// Track for cleanup
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
if (data.sessions?.length > 0) {
cleanup.trackSession(data.sessions[data.sessions.length - 1].id);
if (data?.length > 0) {
cleanup.trackSession(data[data.length - 1].id);
}
// Delete session
+6 -6
View File
@@ -91,8 +91,8 @@ describe('Session Input E2E', () => {
// Track session for cleanup
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
if (data.sessions?.length > 0) {
cleanup.trackSession(data.sessions[data.sessions.length - 1].id);
if (data?.length > 0) {
cleanup.trackSession(data[data.length - 1].id);
}
} finally {
@@ -144,8 +144,8 @@ describe('Session Input E2E', () => {
// Track for cleanup
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
if (data.sessions?.length > 0) {
cleanup.trackSession(data.sessions[data.sessions.length - 1].id);
if (data?.length > 0) {
cleanup.trackSession(data[data.length - 1].id);
}
} finally {
@@ -195,8 +195,8 @@ describe('Session Input E2E', () => {
// Track for cleanup
const response = await fetch(`${serverFixture.baseUrl}/api/sessions`);
const data = await response.json();
if (data.sessions?.length > 0) {
cleanup.trackSession(data.sessions[data.sessions.length - 1].id);
if (data?.length > 0) {
cleanup.trackSession(data[data.length - 1].id);
}
} finally {
-472
View File
@@ -1,472 +0,0 @@
/**
* @fileoverview Extended tests for ralph-config module
*
* Additional comprehensive tests for parsing Ralph Loop configuration
* from various file formats.
*/
import { describe, it, expect } from 'vitest';
describe('Ralph Config Parsing Extended', () => {
// Helper function to simulate YAML frontmatter parsing
const parseYamlFrontmatter = (content: string): Record<string, any> => {
const result: Record<string, any> = {};
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return result;
const lines = frontmatterMatch[1].split('\n');
for (const line of lines) {
const match = line.match(/^([a-z-]+):\s*(.+)$/i);
if (match) {
const key = match[1].toLowerCase().replace(/-/g, '_');
let value: any = match[2].trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Parse booleans
if (value === 'true') value = true;
else if (value === 'false') value = false;
// Parse numbers
else if (/^\d+$/.test(value)) value = parseInt(value, 10);
result[key] = value;
}
}
return result;
};
describe('YAML Frontmatter Parsing', () => {
it('should parse basic frontmatter', () => {
const content = `---
enabled: true
iteration: 5
max-iterations: 50
completion-promise: "COMPLETE"
---
# Content here`;
const config = parseYamlFrontmatter(content);
expect(config.enabled).toBe(true);
expect(config.iteration).toBe(5);
expect(config.max_iterations).toBe(50);
expect(config.completion_promise).toBe('COMPLETE');
});
it('should handle single-quoted values', () => {
const content = `---
completion-promise: 'SINGLE_QUOTED'
---`;
const config = parseYamlFrontmatter(content);
expect(config.completion_promise).toBe('SINGLE_QUOTED');
});
it('should handle unquoted values', () => {
const content = `---
completion-promise: UNQUOTED
---`;
const config = parseYamlFrontmatter(content);
expect(config.completion_promise).toBe('UNQUOTED');
});
it('should parse boolean false', () => {
const content = `---
enabled: false
---`;
const config = parseYamlFrontmatter(content);
expect(config.enabled).toBe(false);
});
it('should handle missing frontmatter', () => {
const content = `# No frontmatter here
Just regular content`;
const config = parseYamlFrontmatter(content);
expect(Object.keys(config)).toHaveLength(0);
});
it('should handle empty frontmatter', () => {
const content = `---
---
# Empty frontmatter`;
const config = parseYamlFrontmatter(content);
expect(Object.keys(config)).toHaveLength(0);
});
it('should handle various iteration values', () => {
for (const iteration of [0, 1, 10, 100, 1000]) {
const content = `---
iteration: ${iteration}
---`;
const config = parseYamlFrontmatter(content);
expect(config.iteration).toBe(iteration);
}
});
it('should handle various max-iterations values', () => {
for (const maxIter of [10, 50, 100, 500, 1000]) {
const content = `---
max-iterations: ${maxIter}
---`;
const config = parseYamlFrontmatter(content);
expect(config.max_iterations).toBe(maxIter);
}
});
it('should handle completion phrases with hyphens', () => {
const content = `---
completion-promise: "TESTS-PASS"
---`;
const config = parseYamlFrontmatter(content);
expect(config.completion_promise).toBe('TESTS-PASS');
});
it('should handle completion phrases with underscores', () => {
const content = `---
completion-promise: "ALL_TASKS_DONE"
---`;
const config = parseYamlFrontmatter(content);
expect(config.completion_promise).toBe('ALL_TASKS_DONE');
});
it('should handle completion phrases with numbers', () => {
const content = `---
completion-promise: "TASK_123_COMPLETE"
---`;
const config = parseYamlFrontmatter(content);
expect(config.completion_promise).toBe('TASK_123_COMPLETE');
});
it('should handle lowercase keys', () => {
const content = `---
enabled: true
iteration: 5
---`;
const config = parseYamlFrontmatter(content);
expect(config.enabled).toBe(true);
expect(config.iteration).toBe(5);
});
it('should handle extra whitespace', () => {
const content = `---
enabled: true
iteration: 5
---`;
const config = parseYamlFrontmatter(content);
expect(config.enabled).toBe(true);
expect(config.iteration).toBe(5);
});
it('should handle mixed case values (booleans)', () => {
const content = `---
enabled: true
---`;
const config = parseYamlFrontmatter(content);
expect(config.enabled).toBe(true);
});
});
describe('Promise Tag Extraction', () => {
const extractPromiseTag = (content: string): string | null => {
const match = content.match(/<promise>([A-Z0-9_-]+)<\/promise>/);
return match ? match[1] : null;
};
it('should extract simple promise tag', () => {
expect(extractPromiseTag('<promise>COMPLETE</promise>')).toBe('COMPLETE');
});
it('should extract promise tag with underscores', () => {
expect(extractPromiseTag('<promise>ALL_DONE</promise>')).toBe('ALL_DONE');
});
it('should extract promise tag with hyphens', () => {
expect(extractPromiseTag('<promise>TESTS-PASS</promise>')).toBe('TESTS-PASS');
});
it('should extract promise tag with numbers', () => {
expect(extractPromiseTag('<promise>TASK123</promise>')).toBe('TASK123');
});
it('should return null for missing tag', () => {
expect(extractPromiseTag('No promise tag here')).toBeNull();
});
it('should return null for malformed tags', () => {
expect(extractPromiseTag('<promise>lowercase</promise>')).toBeNull();
expect(extractPromiseTag('<promise></promise>')).toBeNull();
});
it('should handle embedded promise tags', () => {
const content = 'Text before <promise>EMBEDDED</promise> text after';
expect(extractPromiseTag(content)).toBe('EMBEDDED');
});
it('should handle multiline content', () => {
const content = `
Line 1
Line 2 with <promise>MULTILINE</promise>
Line 3
`;
expect(extractPromiseTag(content)).toBe('MULTILINE');
});
});
describe('CLAUDE.md Parsing', () => {
const extractFromClaudeMd = (content: string): { phrase: string | null; found: boolean } => {
// Look for completion phrase in Ralph Loop section
const ralphSectionMatch = content.match(/## Ralph Wiggum Loop[\s\S]*?(?=##|$)/);
if (!ralphSectionMatch) {
return { phrase: null, found: false };
}
const section = ralphSectionMatch[0];
const promiseMatch = section.match(/[Cc]ompletion [Pp]hrase:?\s*[`"']?<promise>([A-Z0-9_-]+)<\/promise>[`"']?/);
return {
phrase: promiseMatch ? promiseMatch[1] : null,
found: true,
};
};
it('should extract phrase from Ralph Wiggum Loop section', () => {
const content = `
## Ralph Wiggum Loop
Completion Phrase: \`<promise>COMPLETE</promise>\`
### How to use
...
`;
const result = extractFromClaudeMd(content);
expect(result.found).toBe(true);
expect(result.phrase).toBe('COMPLETE');
});
it('should handle different quote styles', () => {
const content1 = `## Ralph Wiggum Loop
Completion phrase: "<promise>DOUBLE</promise>"`;
const content2 = `## Ralph Wiggum Loop
Completion phrase: '<promise>SINGLE</promise>'`;
expect(extractFromClaudeMd(content1).phrase).toBe('DOUBLE');
expect(extractFromClaudeMd(content2).phrase).toBe('SINGLE');
});
it('should return null if section not found', () => {
const content = `
## Different Section
Some content
`;
const result = extractFromClaudeMd(content);
expect(result.found).toBe(false);
expect(result.phrase).toBeNull();
});
it('should handle Ralph Wiggum Loop section without phrase', () => {
const content = `
## Ralph Wiggum Loop
Just some text without a completion phrase
`;
const result = extractFromClaudeMd(content);
expect(result.found).toBe(true);
expect(result.phrase).toBeNull();
});
});
describe('Configuration Priority', () => {
it('should prioritize ralph-loop.local.md over CLAUDE.md', () => {
const localConfig = { completion_promise: 'FROM_LOCAL' };
const claudeConfig = { phrase: 'FROM_CLAUDE' };
// Priority: local > claude
const finalPhrase = localConfig.completion_promise || claudeConfig.phrase;
expect(finalPhrase).toBe('FROM_LOCAL');
});
it('should fallback to CLAUDE.md when local is missing', () => {
const localConfig = {};
const claudeConfig = { phrase: 'FROM_CLAUDE' };
const finalPhrase = (localConfig as any).completion_promise || claudeConfig.phrase;
expect(finalPhrase).toBe('FROM_CLAUDE');
});
it('should handle both missing', () => {
const localConfig = {};
const claudeConfig = { phrase: null };
const finalPhrase = (localConfig as any).completion_promise || claudeConfig.phrase || null;
expect(finalPhrase).toBeNull();
});
});
describe('Iteration and Max Iterations', () => {
it('should handle iteration at start', () => {
const config = { iteration: 0, max_iterations: 50 };
expect(config.iteration).toBe(0);
expect(config.iteration < config.max_iterations).toBe(true);
});
it('should handle iteration at end', () => {
const config = { iteration: 50, max_iterations: 50 };
expect(config.iteration).toBe(50);
expect(config.iteration >= config.max_iterations).toBe(true);
});
it('should handle no max iterations', () => {
const config = { iteration: 5, max_iterations: null };
expect(config.max_iterations).toBeNull();
});
it('should calculate progress percentage', () => {
const calculateProgress = (current: number, max: number | null): number | null => {
if (max === null || max === 0) return null;
return Math.round((current / max) * 100);
};
expect(calculateProgress(5, 50)).toBe(10);
expect(calculateProgress(25, 50)).toBe(50);
expect(calculateProgress(50, 50)).toBe(100);
expect(calculateProgress(0, 50)).toBe(0);
expect(calculateProgress(5, null)).toBeNull();
expect(calculateProgress(5, 0)).toBeNull();
});
});
describe('File Path Validation', () => {
const isValidRalphConfigPath = (path: string): boolean => {
return path.endsWith('.claude/ralph-loop.local.md') ||
path.endsWith('.claude/ralph-loop.md') ||
path.endsWith('CLAUDE.md');
};
it('should accept valid paths', () => {
expect(isValidRalphConfigPath('/project/.claude/ralph-loop.local.md')).toBe(true);
expect(isValidRalphConfigPath('/project/.claude/ralph-loop.md')).toBe(true);
expect(isValidRalphConfigPath('/project/CLAUDE.md')).toBe(true);
});
it('should reject invalid paths', () => {
expect(isValidRalphConfigPath('/project/random.md')).toBe(false);
expect(isValidRalphConfigPath('/project/.claude/other.md')).toBe(false);
expect(isValidRalphConfigPath('/project/claude.md')).toBe(false); // lowercase
});
});
describe('Enabled State Detection', () => {
const isRalphEnabled = (config: { enabled?: boolean; iteration?: number; max_iterations?: number }): boolean => {
// Enabled if explicitly true, or if there's iteration data
return config.enabled === true ||
(typeof config.iteration === 'number' && config.iteration > 0) ||
(typeof config.max_iterations === 'number' && config.max_iterations > 0);
};
it('should detect enabled=true', () => {
expect(isRalphEnabled({ enabled: true })).toBe(true);
});
it('should detect enabled=false', () => {
expect(isRalphEnabled({ enabled: false })).toBe(false);
});
it('should detect enabled from iteration', () => {
expect(isRalphEnabled({ iteration: 5 })).toBe(true);
});
it('should detect enabled from max_iterations', () => {
expect(isRalphEnabled({ max_iterations: 50 })).toBe(true);
});
it('should handle empty config', () => {
expect(isRalphEnabled({})).toBe(false);
});
it('should handle zero iteration', () => {
expect(isRalphEnabled({ iteration: 0 })).toBe(false);
});
});
describe('Config Validation', () => {
const validateConfig = (config: Record<string, any>): { valid: boolean; errors: string[] } => {
const errors: string[] = [];
if (config.enabled !== undefined && typeof config.enabled !== 'boolean') {
errors.push('enabled must be a boolean');
}
if (config.iteration !== undefined) {
if (typeof config.iteration !== 'number' || config.iteration < 0) {
errors.push('iteration must be a non-negative number');
}
}
if (config.max_iterations !== undefined) {
if (typeof config.max_iterations !== 'number' || config.max_iterations < 1) {
errors.push('max_iterations must be a positive number');
}
}
if (config.completion_promise !== undefined) {
if (typeof config.completion_promise !== 'string' || !/^[A-Z0-9_-]+$/.test(config.completion_promise)) {
errors.push('completion_promise must be uppercase alphanumeric with hyphens/underscores');
}
}
return { valid: errors.length === 0, errors };
};
it('should validate correct config', () => {
const result = validateConfig({
enabled: true,
iteration: 5,
max_iterations: 50,
completion_promise: 'COMPLETE',
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should catch invalid enabled', () => {
const result = validateConfig({ enabled: 'yes' });
expect(result.valid).toBe(false);
expect(result.errors).toContain('enabled must be a boolean');
});
it('should catch negative iteration', () => {
const result = validateConfig({ iteration: -1 });
expect(result.valid).toBe(false);
expect(result.errors).toContain('iteration must be a non-negative number');
});
it('should catch zero max_iterations', () => {
const result = validateConfig({ max_iterations: 0 });
expect(result.valid).toBe(false);
expect(result.errors).toContain('max_iterations must be a positive number');
});
it('should catch lowercase completion_promise', () => {
const result = validateConfig({ completion_promise: 'lowercase' });
expect(result.valid).toBe(false);
expect(result.errors).toContain('completion_promise must be uppercase alphanumeric with hyphens/underscores');
});
it('should allow empty config', () => {
const result = validateConfig({});
expect(result.valid).toBe(true);
});
});
});
+7 -1
View File
@@ -18,7 +18,7 @@
*/
import { execSync } from 'node:child_process';
import { beforeAll, afterAll, afterEach } from 'vitest';
import { beforeAll, afterAll, afterEach, vi } from 'vitest';
/** Maximum concurrent screen sessions allowed during tests */
const MAX_CONCURRENT_SCREENS = 10;
@@ -316,6 +316,12 @@ beforeAll(async () => {
console.log(`[Test Setup] ${preExistingScreens.size} pre-existing screens preserved`);
});
afterEach(() => {
// Clean up mocks and timers between tests for proper isolation
vi.clearAllMocks();
vi.useRealTimers();
});
afterAll(async () => {
console.log('[Test Setup] Final cleanup of test-created resources...');