diff --git a/CLAUDE.md b/CLAUDE.md
index ad5d7dd0..e8115ef9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,7 +16,7 @@ When user says "COM":
1. Increment version in BOTH `package.json` AND `CLAUDE.md`
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
-**Version**: 0.1442 (must match `package.json`)
+**Version**: 0.1443 (must match `package.json`)
## Project Overview
diff --git a/package.json b/package.json
index b9d456d6..fbece84b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "claudeman",
- "version": "0.1442",
+ "version": "0.1443",
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
diff --git a/src/web/public/app.js b/src/web/public/app.js
index ba3f4276..92ad3b1c 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -78,6 +78,106 @@ const BUILTIN_RESPAWN_PRESETS = [
},
];
+// ============================================================================
+// Mobile Detection
+// ============================================================================
+
+/**
+ * MobileDetection - Detects device type and touch capability.
+ * Updates body classes for CSS targeting.
+ */
+const MobileDetection = {
+ /** Check if device supports touch input */
+ isTouchDevice() {
+ return 'ontouchstart' in window ||
+ navigator.maxTouchPoints > 0 ||
+ (window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
+ },
+
+ /** Check if device is iOS (iPhone, iPad, iPod) */
+ isIOS() {
+ return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ },
+
+ /** Check if browser is Safari */
+ isSafari() {
+ return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+ },
+
+ /** Check if screen is small (phone-sized, <430px) */
+ isSmallScreen() {
+ return window.innerWidth < 430;
+ },
+
+ /** Check if screen is medium (tablet-sized, 430-768px) */
+ isMediumScreen() {
+ return window.innerWidth >= 430 && window.innerWidth < 768;
+ },
+
+ /** Get device type based on screen width */
+ getDeviceType() {
+ const width = window.innerWidth;
+ if (width < 430) return 'mobile';
+ if (width < 768) return 'tablet';
+ return 'desktop';
+ },
+
+ /** Update body classes based on device detection */
+ updateBodyClass() {
+ const body = document.body;
+ const deviceType = this.getDeviceType();
+ const isTouch = this.isTouchDevice();
+
+ // Remove existing device classes
+ body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser');
+
+ // Add current device class
+ body.classList.add(`device-${deviceType}`);
+
+ // Add touch device class if applicable
+ if (isTouch) {
+ body.classList.add('touch-device');
+ }
+
+ // Add iOS-specific class for safe area handling
+ if (this.isIOS()) {
+ body.classList.add('ios-device');
+ }
+
+ // Add Safari class for browser-specific fixes
+ if (this.isSafari()) {
+ body.classList.add('safari-browser');
+ }
+ },
+
+ /** Initialize mobile detection and set up resize listener */
+ init() {
+ this.updateBodyClass();
+ // Debounced resize handler
+ let resizeTimeout;
+ window.addEventListener('resize', () => {
+ clearTimeout(resizeTimeout);
+ resizeTimeout = setTimeout(() => this.updateBodyClass(), 100);
+ });
+ }
+};
+
+/**
+ * Get unified coordinates from mouse or touch event.
+ * @param {MouseEvent|TouchEvent} e - The event
+ * @returns {{ clientX: number, clientY: number }} Coordinates
+ */
+function getEventCoords(e) {
+ if (e.touches && e.touches.length > 0) {
+ return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
+ }
+ if (e.changedTouches && e.changedTouches.length > 0) {
+ return { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY };
+ }
+ return { clientX: e.clientX, clientY: e.clientY };
+}
+
// ============================================================================
// Accessibility: Focus Trap for Modals
// ============================================================================
@@ -773,6 +873,8 @@ class ClaudemanApp {
}
init() {
+ // Initialize mobile detection first (adds device classes to body)
+ MobileDetection.init();
this.initTerminal();
this.loadFontSize();
this.applyHeaderVisibilitySettings();
@@ -4950,7 +5052,7 @@ class ClaudemanApp {
let dragUpdateScheduled = false;
let agentStartPositions = new Map(); // Track agent window start positions during drag
- const mousedownHandler = (e) => {
+ const startHandler = (e) => {
// Don't drag if clicking buttons
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
@@ -4962,8 +5064,9 @@ class ClaudemanApp {
this.wizardPosition = { left: rect.left, top: rect.top };
}
- startX = e.clientX;
- startY = e.clientY;
+ const coords = getEventCoords(e);
+ startX = coords.clientX;
+ startY = coords.clientY;
startLeft = this.wizardPosition.left;
startTop = this.wizardPosition.top;
@@ -4989,8 +5092,9 @@ class ClaudemanApp {
const moveHandler = (e) => {
if (!isDragging) return;
- const dx = e.clientX - startX;
- const dy = e.clientY - startY;
+ const coords = getEventCoords(e);
+ const dx = coords.clientX - startX;
+ const dy = coords.clientY - startY;
// Constrain to viewport
const winWidth = wizardContent.offsetWidth || 480;
@@ -5025,13 +5129,18 @@ class ClaudemanApp {
}
};
- header.addEventListener('mousedown', mousedownHandler);
+ // Mouse events
+ header.addEventListener('mousedown', startHandler);
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
+ // Touch events
+ header.addEventListener('touchstart', startHandler, { passive: false });
+ document.addEventListener('touchmove', moveHandler, { passive: false });
+ document.addEventListener('touchend', upHandler);
this.wizardDragListeners = {
header,
- mousedown: mousedownHandler,
+ start: startHandler,
move: moveHandler,
up: upHandler,
};
@@ -5039,10 +5148,15 @@ class ClaudemanApp {
cleanupWizardDragging() {
if (this.wizardDragListeners) {
- const { header, mousedown, move, up } = this.wizardDragListeners;
- header?.removeEventListener('mousedown', mousedown);
+ const { header, start, move, up } = this.wizardDragListeners;
+ // Mouse events
+ header?.removeEventListener('mousedown', start);
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
+ // Touch events
+ header?.removeEventListener('touchstart', start);
+ document.removeEventListener('touchmove', move);
+ document.removeEventListener('touchend', up);
this.wizardDragListeners = null;
}
}
@@ -8908,28 +9022,32 @@ class ClaudemanApp {
let isDragging = false;
let startX, startY, startLeft, startTop;
- const onMouseDown = (e) => {
+ const onStart = (e) => {
// Only drag from header, not from buttons
if (e.target.closest('button')) return;
if (!panel.classList.contains('detached')) return;
isDragging = true;
- startX = e.clientX;
- startY = e.clientY;
+ const coords = getEventCoords(e);
+ startX = coords.clientX;
+ startY = coords.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onEnd);
+ document.addEventListener('touchmove', onMove, { passive: false });
+ document.addEventListener('touchend', onEnd);
e.preventDefault();
};
- const onMouseMove = (e) => {
+ const onMove = (e) => {
if (!isDragging) return;
- const dx = e.clientX - startX;
- const dy = e.clientY - startY;
+ const coords = getEventCoords(e);
+ const dx = coords.clientX - startX;
+ const dy = coords.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
@@ -8943,16 +9061,21 @@ class ClaudemanApp {
panel.style.top = newTop + 'px';
};
- const onMouseUp = () => {
+ const onEnd = () => {
isDragging = false;
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onEnd);
+ document.removeEventListener('touchmove', onMove);
+ document.removeEventListener('touchend', onEnd);
};
// Remove existing listeners before adding new ones
header.removeEventListener('mousedown', header._dragHandler);
- header._dragHandler = onMouseDown;
- header.addEventListener('mousedown', onMouseDown);
+ header.removeEventListener('touchstart', header._touchDragHandler);
+ header._dragHandler = onStart;
+ header._touchDragHandler = onStart;
+ header.addEventListener('mousedown', onStart);
+ header.addEventListener('touchstart', onStart, { passive: false });
}
// ========== Subagents Panel Detach & Drag ==========
@@ -8994,28 +9117,32 @@ class ClaudemanApp {
let isDragging = false;
let startX, startY, startLeft, startTop;
- const onMouseDown = (e) => {
+ const onStart = (e) => {
// Only drag from header, not from buttons
if (e.target.closest('button')) return;
if (!panel.classList.contains('detached')) return;
isDragging = true;
- startX = e.clientX;
- startY = e.clientY;
+ const coords = getEventCoords(e);
+ startX = coords.clientX;
+ startY = coords.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onEnd);
+ document.addEventListener('touchmove', onMove, { passive: false });
+ document.addEventListener('touchend', onEnd);
e.preventDefault();
};
- const onMouseMove = (e) => {
+ const onMove = (e) => {
if (!isDragging) return;
- const dx = e.clientX - startX;
- const dy = e.clientY - startY;
+ const coords = getEventCoords(e);
+ const dx = coords.clientX - startX;
+ const dy = coords.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
@@ -9029,16 +9156,21 @@ class ClaudemanApp {
panel.style.top = newTop + 'px';
};
- const onMouseUp = () => {
+ const onEnd = () => {
isDragging = false;
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onEnd);
+ document.removeEventListener('touchmove', onMove);
+ document.removeEventListener('touchend', onEnd);
};
// Remove existing listeners before adding new ones
header.removeEventListener('mousedown', header._dragHandler);
- header._dragHandler = onMouseDown;
- header.addEventListener('mousedown', onMouseDown);
+ header.removeEventListener('touchstart', header._touchDragHandler);
+ header._dragHandler = onStart;
+ header._touchDragHandler = onStart;
+ header.addEventListener('mousedown', onStart);
+ header.addEventListener('touchstart', onStart, { passive: false });
}
renderTaskPanel() {
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 49b5e213..aedc3254 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -2,7 +2,7 @@
-
+
Claudeman
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 495affc3..87ba36ff 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -26,6 +26,15 @@
--session-blue: #3b82f6;
--session-purple: #a855f7;
--session-pink: #ec4899;
+
+ /* Safe area insets for notched devices (iPhone X+, Dynamic Island) */
+ --safe-area-top: env(safe-area-inset-top, 0px);
+ --safe-area-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-area-left: env(safe-area-inset-left, 0px);
+ --safe-area-right: env(safe-area-inset-right, 0px);
+
+ /* Touch target minimum (iOS Human Interface Guidelines) */
+ --touch-target-min: 44px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -7776,3 +7785,343 @@ kbd {
.advanced-options-content {
padding-left: 0.5rem;
}
+
+/* ============================================================================
+ Mobile & Touch Device Optimizations
+ ============================================================================ */
+
+/* Touch-friendly sizing for touch devices */
+.touch-device .btn-toolbar {
+ min-height: var(--touch-target-min);
+ min-width: var(--touch-target-min);
+}
+
+.touch-device .btn-icon-header,
+.touch-device .btn-icon-sm,
+.touch-device .btn-icon-only {
+ min-height: var(--touch-target-min);
+ min-width: var(--touch-target-min);
+}
+
+.touch-device .session-tab {
+ min-height: var(--touch-target-min);
+ padding: 0.5rem 0.75rem;
+}
+
+/* Always show close/gear buttons on touch devices (no hover) */
+.touch-device .session-tab .tab-close,
+.touch-device .session-tab .tab-gear {
+ opacity: 1;
+ width: auto;
+ padding: 0.2rem 0.4rem;
+}
+
+.touch-device .tab-count-btn {
+ min-height: var(--touch-target-min);
+ min-width: var(--touch-target-min);
+}
+
+/* ============================================================================
+ Tablet Breakpoint (430px - 768px)
+ ============================================================================ */
+@media (max-width: 768px) and (min-width: 430px) {
+ /* Safe area padding for iOS devices */
+ .ios-device .app {
+ padding-left: var(--safe-area-left);
+ padding-right: var(--safe-area-right);
+ }
+
+ /* Increase header height for better touch targets */
+ .header {
+ min-height: 48px;
+ padding: 0.5rem 0.75rem;
+ }
+
+ /* Stack panels vertically */
+ .monitor-panel,
+ .subagents-panel {
+ width: 100%;
+ max-width: 100%;
+ left: 0;
+ right: 0;
+ border-radius: 8px 8px 0 0;
+ }
+
+ /* Larger toolbar buttons */
+ .btn-toolbar {
+ padding: 0.5rem 1rem;
+ font-size: 0.8rem;
+ }
+
+ /* Session tabs horizontal scroll */
+ .session-tabs {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ }
+
+ .session-tabs::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+/* ============================================================================
+ Phone Breakpoint (<430px)
+ ============================================================================ */
+@media (max-width: 430px) {
+ /* Safe area padding for iOS devices */
+ .ios-device .app {
+ padding-top: var(--safe-area-top);
+ padding-left: var(--safe-area-left);
+ padding-right: var(--safe-area-right);
+ }
+
+ /* Hide header brand on phones */
+ .header-brand {
+ display: none;
+ }
+
+ /* Hide font controls on phones */
+ .header-font-controls {
+ display: none;
+ }
+
+ /* Hide system stats on phones */
+ .header-system-stats {
+ display: none;
+ }
+
+ /* Compact header */
+ .header {
+ min-height: 48px;
+ padding: 0.4rem;
+ gap: 0.25rem;
+ }
+
+ .header-right {
+ padding-left: 0.5rem;
+ gap: 0.25rem;
+ }
+
+ /* Full-width session tabs with horizontal scroll */
+ .session-tabs {
+ flex: 1;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ max-height: 56px;
+ }
+
+ .session-tabs::-webkit-scrollbar {
+ display: none;
+ }
+
+ .session-tab {
+ flex-shrink: 0;
+ min-height: var(--touch-target-min);
+ padding: 0.5rem 0.75rem;
+ }
+
+ /* Simplified toolbar */
+ .toolbar {
+ height: auto;
+ min-height: var(--toolbar-height);
+ padding: 0.5rem;
+ padding-bottom: calc(0.5rem + var(--safe-area-bottom));
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ /* Hide secondary toolbar groups */
+ .toolbar-center {
+ display: none;
+ }
+
+ .toolbar-left .toolbar-group:not(:first-child) {
+ display: none;
+ }
+
+ /* Full-width primary toolbar group */
+ .toolbar-left {
+ flex: 1;
+ justify-content: center;
+ }
+
+ .toolbar-left .toolbar-group:first-child {
+ flex: 1;
+ justify-content: space-around;
+ }
+
+ /* Larger toolbar buttons for touch */
+ .btn-toolbar {
+ min-height: var(--touch-target-min);
+ padding: 0.5rem 1rem;
+ font-size: 0.85rem;
+ }
+
+ .btn-toolbar.btn-claude {
+ flex: 1;
+ justify-content: center;
+ }
+
+ /* Full-width panels */
+ .monitor-panel,
+ .subagents-panel {
+ width: 100%;
+ max-width: 100%;
+ left: 0;
+ right: 0;
+ border-radius: 8px 8px 0 0;
+ }
+
+ /* Full-screen modals on phones */
+ .modal-content {
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ max-height: 100%;
+ border-radius: 0;
+ margin: 0;
+ }
+
+ /* Modal safe area padding */
+ .ios-device .modal-content {
+ padding-top: var(--safe-area-top);
+ padding-bottom: var(--safe-area-bottom);
+ }
+
+ .modal-header {
+ padding: 1rem;
+ }
+
+ .modal-body {
+ padding: 1rem;
+ flex: 1;
+ overflow-y: auto;
+ }
+
+ .modal-footer,
+ .form-actions {
+ padding: 1rem;
+ padding-bottom: calc(1rem + var(--safe-area-bottom));
+ }
+
+ /* Terminal container safe area */
+ .ios-device .terminal-container {
+ padding-bottom: var(--safe-area-bottom);
+ }
+
+ /* Welcome overlay adjustments */
+ .welcome-content {
+ padding: 1.5rem 1rem;
+ }
+
+ .welcome-title {
+ font-size: 1.4rem;
+ }
+
+ .welcome-actions {
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ .welcome-btn {
+ width: 100%;
+ justify-content: center;
+ min-height: var(--touch-target-min);
+ }
+
+ /* Ralph wizard responsive */
+ .modal-wizard {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .wizard-progress {
+ padding: 0.75rem;
+ }
+
+ .wizard-step-label {
+ display: none;
+ }
+
+ /* Tab count group responsive */
+ .tab-count-group {
+ margin: 0;
+ }
+
+ .tab-count-btn {
+ min-width: var(--touch-target-min);
+ min-height: var(--touch-target-min);
+ }
+
+ /* Case select responsive */
+ .case-select-group {
+ flex: 0 0 auto;
+ }
+
+ .toolbar-select {
+ font-size: 0.8rem;
+ padding: 0.5rem 1.5rem 0.5rem 0.75rem;
+ }
+}
+
+/* ============================================================================
+ Virtual Keyboard Handling
+ ============================================================================ */
+.keyboard-visible .toolbar {
+ display: none;
+}
+
+.keyboard-visible .monitor-panel,
+.keyboard-visible .subagents-panel {
+ display: none;
+}
+
+/* Ensure terminal fills space when keyboard is visible */
+.keyboard-visible .main {
+ height: 100%;
+}
+
+/* ============================================================================
+ iOS Safari Specific Fixes
+ ============================================================================ */
+.ios-device.safari-browser {
+ /* Prevent pull-to-refresh */
+ overscroll-behavior: none;
+}
+
+.ios-device.safari-browser .terminal-container {
+ /* Prevent rubber-banding in terminal */
+ -webkit-overflow-scrolling: auto;
+}
+
+/* ============================================================================
+ Hover State Fallbacks for Touch
+ ============================================================================ */
+@media (hover: none) and (pointer: coarse) {
+ /* Remove hover-dependent UI patterns */
+ .session-tab .tab-close,
+ .session-tab .tab-gear {
+ opacity: 1;
+ width: auto;
+ padding: 0.2rem 0.4rem;
+ }
+
+ /* Simplify button hover states */
+ .btn-toolbar:hover {
+ transform: none;
+ }
+
+ .btn-icon-header:hover {
+ transform: none;
+ }
+
+ /* Show dropdown on tap instead of hover */
+ .subagent-dropdown-trigger:active + .subagent-dropdown-menu,
+ .subagent-dropdown-trigger:focus + .subagent-dropdown-menu {
+ display: block;
+ }
+}
diff --git a/test/data-structures.test.ts b/test/data-structures.test.ts
deleted file mode 100644
index d553873e..00000000
--- a/test/data-structures.test.ts
+++ /dev/null
@@ -1,1062 +0,0 @@
-/**
- * @fileoverview Tests for data structure utilities
- *
- * Tests various data structure patterns like LRU cache, priority queue,
- * ring buffer, and other utilities used in the application.
- */
-
-import { describe, it, expect } from 'vitest';
-
-describe('Data Structures', () => {
- describe('LRU Cache', () => {
- class LRUCache {
- private cache: Map = new Map();
- private capacity: number;
-
- constructor(capacity: number) {
- this.capacity = capacity;
- }
-
- get(key: K): V | undefined {
- if (!this.cache.has(key)) return undefined;
- const value = this.cache.get(key)!;
- // Move to end (most recently used)
- this.cache.delete(key);
- this.cache.set(key, value);
- return value;
- }
-
- set(key: K, value: V): void {
- if (this.cache.has(key)) {
- this.cache.delete(key);
- } else if (this.cache.size >= this.capacity) {
- // Remove oldest (first key)
- const firstKey = this.cache.keys().next().value;
- this.cache.delete(firstKey);
- }
- this.cache.set(key, value);
- }
-
- has(key: K): boolean {
- return this.cache.has(key);
- }
-
- delete(key: K): boolean {
- return this.cache.delete(key);
- }
-
- clear(): void {
- this.cache.clear();
- }
-
- get size(): number {
- return this.cache.size;
- }
-
- keys(): K[] {
- return Array.from(this.cache.keys());
- }
- }
-
- it('should store and retrieve values', () => {
- const cache = new LRUCache(3);
- cache.set('a', 1);
- cache.set('b', 2);
- expect(cache.get('a')).toBe(1);
- expect(cache.get('b')).toBe(2);
- });
-
- it('should evict oldest entry when full', () => {
- const cache = new LRUCache(2);
- cache.set('a', 1);
- cache.set('b', 2);
- cache.set('c', 3); // Should evict 'a'
- expect(cache.get('a')).toBeUndefined();
- expect(cache.get('b')).toBe(2);
- expect(cache.get('c')).toBe(3);
- });
-
- it('should update recently used on get', () => {
- const cache = new LRUCache(2);
- cache.set('a', 1);
- cache.set('b', 2);
- cache.get('a'); // Touch 'a'
- cache.set('c', 3); // Should evict 'b', not 'a'
- expect(cache.get('a')).toBe(1);
- expect(cache.get('b')).toBeUndefined();
- });
-
- it('should report correct size', () => {
- const cache = new LRUCache(5);
- expect(cache.size).toBe(0);
- cache.set('a', 1);
- expect(cache.size).toBe(1);
- cache.set('b', 2);
- expect(cache.size).toBe(2);
- });
-
- it('should clear all entries', () => {
- const cache = new LRUCache(5);
- cache.set('a', 1);
- cache.set('b', 2);
- cache.clear();
- expect(cache.size).toBe(0);
- expect(cache.get('a')).toBeUndefined();
- });
-
- it('should delete specific entries', () => {
- const cache = new LRUCache(5);
- cache.set('a', 1);
- cache.delete('a');
- expect(cache.has('a')).toBe(false);
- });
-
- it('should check existence', () => {
- const cache = new LRUCache(5);
- cache.set('a', 1);
- expect(cache.has('a')).toBe(true);
- expect(cache.has('b')).toBe(false);
- });
-
- it('should list keys', () => {
- const cache = new LRUCache(5);
- cache.set('a', 1);
- cache.set('b', 2);
- expect(cache.keys()).toEqual(['a', 'b']);
- });
- });
-
- describe('Priority Queue', () => {
- class PriorityQueue {
- private items: { value: T; priority: number }[] = [];
-
- enqueue(value: T, priority: number): void {
- const item = { value, priority };
- let added = false;
- for (let i = 0; i < this.items.length; i++) {
- if (priority < this.items[i].priority) {
- this.items.splice(i, 0, item);
- added = true;
- break;
- }
- }
- if (!added) {
- this.items.push(item);
- }
- }
-
- dequeue(): T | undefined {
- return this.items.shift()?.value;
- }
-
- peek(): T | undefined {
- return this.items[0]?.value;
- }
-
- isEmpty(): boolean {
- return this.items.length === 0;
- }
-
- get size(): number {
- return this.items.length;
- }
-
- clear(): void {
- this.items = [];
- }
- }
-
- it('should dequeue in priority order', () => {
- const pq = new PriorityQueue();
- pq.enqueue('low', 10);
- pq.enqueue('high', 1);
- pq.enqueue('medium', 5);
-
- expect(pq.dequeue()).toBe('high');
- expect(pq.dequeue()).toBe('medium');
- expect(pq.dequeue()).toBe('low');
- });
-
- it('should peek without removing', () => {
- const pq = new PriorityQueue();
- pq.enqueue('item', 1);
- expect(pq.peek()).toBe('item');
- expect(pq.size).toBe(1);
- });
-
- it('should report empty state', () => {
- const pq = new PriorityQueue();
- expect(pq.isEmpty()).toBe(true);
- pq.enqueue('item', 1);
- expect(pq.isEmpty()).toBe(false);
- pq.dequeue();
- expect(pq.isEmpty()).toBe(true);
- });
-
- it('should handle same priority', () => {
- const pq = new PriorityQueue();
- pq.enqueue('first', 1);
- pq.enqueue('second', 1);
- expect(pq.dequeue()).toBe('first');
- expect(pq.dequeue()).toBe('second');
- });
-
- it('should clear all items', () => {
- const pq = new PriorityQueue();
- pq.enqueue('a', 1);
- pq.enqueue('b', 2);
- pq.clear();
- expect(pq.isEmpty()).toBe(true);
- });
- });
-
- describe('Ring Buffer', () => {
- class RingBuffer {
- private buffer: (T | undefined)[];
- private head = 0;
- private tail = 0;
- private count = 0;
- private capacity: number;
-
- constructor(capacity: number) {
- this.capacity = capacity;
- this.buffer = new Array(capacity);
- }
-
- push(item: T): T | undefined {
- let evicted: T | undefined;
- if (this.count === this.capacity) {
- evicted = this.buffer[this.head];
- this.head = (this.head + 1) % this.capacity;
- } else {
- this.count++;
- }
- this.buffer[this.tail] = item;
- this.tail = (this.tail + 1) % this.capacity;
- return evicted;
- }
-
- shift(): T | undefined {
- if (this.count === 0) return undefined;
- const item = this.buffer[this.head];
- this.buffer[this.head] = undefined;
- this.head = (this.head + 1) % this.capacity;
- this.count--;
- return item;
- }
-
- peek(): T | undefined {
- if (this.count === 0) return undefined;
- return this.buffer[this.head];
- }
-
- get size(): number {
- return this.count;
- }
-
- isFull(): boolean {
- return this.count === this.capacity;
- }
-
- isEmpty(): boolean {
- return this.count === 0;
- }
-
- toArray(): T[] {
- const result: T[] = [];
- for (let i = 0; i < this.count; i++) {
- const idx = (this.head + i) % this.capacity;
- result.push(this.buffer[idx]!);
- }
- return result;
- }
-
- clear(): void {
- this.buffer = new Array(this.capacity);
- this.head = 0;
- this.tail = 0;
- this.count = 0;
- }
- }
-
- it('should add and remove items', () => {
- const rb = new RingBuffer(3);
- rb.push(1);
- rb.push(2);
- expect(rb.shift()).toBe(1);
- expect(rb.shift()).toBe(2);
- });
-
- it('should wrap around', () => {
- const rb = new RingBuffer(3);
- rb.push(1);
- rb.push(2);
- rb.push(3);
- rb.shift(); // Remove 1
- rb.push(4); // Should wrap
- expect(rb.toArray()).toEqual([2, 3, 4]);
- });
-
- it('should evict oldest when full', () => {
- const rb = new RingBuffer(2);
- rb.push(1);
- rb.push(2);
- const evicted = rb.push(3);
- expect(evicted).toBe(1);
- expect(rb.toArray()).toEqual([2, 3]);
- });
-
- it('should peek without removing', () => {
- const rb = new RingBuffer(3);
- rb.push(1);
- expect(rb.peek()).toBe(1);
- expect(rb.size).toBe(1);
- });
-
- it('should report full state', () => {
- const rb = new RingBuffer(2);
- expect(rb.isFull()).toBe(false);
- rb.push(1);
- rb.push(2);
- expect(rb.isFull()).toBe(true);
- });
-
- it('should convert to array', () => {
- const rb = new RingBuffer(5);
- rb.push(1);
- rb.push(2);
- rb.push(3);
- expect(rb.toArray()).toEqual([1, 2, 3]);
- });
-
- it('should clear', () => {
- const rb = new RingBuffer(3);
- rb.push(1);
- rb.push(2);
- rb.clear();
- expect(rb.isEmpty()).toBe(true);
- expect(rb.size).toBe(0);
- });
- });
-
- describe('Stack', () => {
- class Stack {
- private items: T[] = [];
- private maxSize: number;
-
- constructor(maxSize: number = Infinity) {
- this.maxSize = maxSize;
- }
-
- push(item: T): boolean {
- if (this.items.length >= this.maxSize) return false;
- this.items.push(item);
- return true;
- }
-
- pop(): T | undefined {
- return this.items.pop();
- }
-
- peek(): T | undefined {
- return this.items[this.items.length - 1];
- }
-
- get size(): number {
- return this.items.length;
- }
-
- isEmpty(): boolean {
- return this.items.length === 0;
- }
-
- isFull(): boolean {
- return this.items.length >= this.maxSize;
- }
-
- clear(): void {
- this.items = [];
- }
-
- toArray(): T[] {
- return [...this.items];
- }
- }
-
- it('should push and pop in LIFO order', () => {
- const stack = new Stack();
- stack.push(1);
- stack.push(2);
- stack.push(3);
- expect(stack.pop()).toBe(3);
- expect(stack.pop()).toBe(2);
- expect(stack.pop()).toBe(1);
- });
-
- it('should peek without removing', () => {
- const stack = new Stack();
- stack.push(1);
- expect(stack.peek()).toBe(1);
- expect(stack.size).toBe(1);
- });
-
- it('should respect max size', () => {
- const stack = new Stack(2);
- expect(stack.push(1)).toBe(true);
- expect(stack.push(2)).toBe(true);
- expect(stack.push(3)).toBe(false);
- expect(stack.size).toBe(2);
- });
-
- it('should report empty state', () => {
- const stack = new Stack();
- expect(stack.isEmpty()).toBe(true);
- stack.push(1);
- expect(stack.isEmpty()).toBe(false);
- });
-
- it('should report full state', () => {
- const stack = new Stack(2);
- expect(stack.isFull()).toBe(false);
- stack.push(1);
- stack.push(2);
- expect(stack.isFull()).toBe(true);
- });
- });
-
- describe('Deque (Double-Ended Queue)', () => {
- class Deque {
- private items: T[] = [];
-
- pushFront(item: T): void {
- this.items.unshift(item);
- }
-
- pushBack(item: T): void {
- this.items.push(item);
- }
-
- popFront(): T | undefined {
- return this.items.shift();
- }
-
- popBack(): T | undefined {
- return this.items.pop();
- }
-
- peekFront(): T | undefined {
- return this.items[0];
- }
-
- peekBack(): T | undefined {
- return this.items[this.items.length - 1];
- }
-
- get size(): number {
- return this.items.length;
- }
-
- isEmpty(): boolean {
- return this.items.length === 0;
- }
-
- clear(): void {
- this.items = [];
- }
-
- toArray(): T[] {
- return [...this.items];
- }
- }
-
- it('should add to front and back', () => {
- const dq = new Deque();
- dq.pushFront(1);
- dq.pushBack(2);
- dq.pushFront(0);
- expect(dq.toArray()).toEqual([0, 1, 2]);
- });
-
- it('should remove from front and back', () => {
- const dq = new Deque();
- dq.pushBack(1);
- dq.pushBack(2);
- dq.pushBack(3);
- expect(dq.popFront()).toBe(1);
- expect(dq.popBack()).toBe(3);
- expect(dq.toArray()).toEqual([2]);
- });
-
- it('should peek front and back', () => {
- const dq = new Deque();
- dq.pushBack(1);
- dq.pushBack(2);
- dq.pushBack(3);
- expect(dq.peekFront()).toBe(1);
- expect(dq.peekBack()).toBe(3);
- expect(dq.size).toBe(3);
- });
- });
-
- describe('Set Operations', () => {
- const union = (a: Set, b: Set): Set => {
- return new Set([...a, ...b]);
- };
-
- const intersection = (a: Set, b: Set): Set => {
- return new Set([...a].filter(x => b.has(x)));
- };
-
- const difference = (a: Set, b: Set): Set => {
- return new Set([...a].filter(x => !b.has(x)));
- };
-
- const symmetricDifference = (a: Set, b: Set): Set => {
- return new Set([...a].filter(x => !b.has(x)).concat([...b].filter(x => !a.has(x))));
- };
-
- const isSubset = (a: Set, b: Set): boolean => {
- return [...a].every(x => b.has(x));
- };
-
- it('should compute union', () => {
- const a = new Set([1, 2, 3]);
- const b = new Set([3, 4, 5]);
- expect([...union(a, b)].sort()).toEqual([1, 2, 3, 4, 5]);
- });
-
- it('should compute intersection', () => {
- const a = new Set([1, 2, 3]);
- const b = new Set([2, 3, 4]);
- expect([...intersection(a, b)].sort()).toEqual([2, 3]);
- });
-
- it('should compute difference', () => {
- const a = new Set([1, 2, 3]);
- const b = new Set([2, 3, 4]);
- expect([...difference(a, b)].sort()).toEqual([1]);
- });
-
- it('should compute symmetric difference', () => {
- const a = new Set([1, 2, 3]);
- const b = new Set([2, 3, 4]);
- expect([...symmetricDifference(a, b)].sort()).toEqual([1, 4]);
- });
-
- it('should check subset', () => {
- const a = new Set([1, 2]);
- const b = new Set([1, 2, 3]);
- expect(isSubset(a, b)).toBe(true);
- expect(isSubset(b, a)).toBe(false);
- });
- });
-
- describe('Trie', () => {
- class TrieNode {
- children: Map = new Map();
- isEndOfWord = false;
- }
-
- class Trie {
- private root = new TrieNode();
-
- insert(word: string): void {
- let node = this.root;
- for (const char of word) {
- if (!node.children.has(char)) {
- node.children.set(char, new TrieNode());
- }
- node = node.children.get(char)!;
- }
- node.isEndOfWord = true;
- }
-
- search(word: string): boolean {
- const node = this.findNode(word);
- return node !== null && node.isEndOfWord;
- }
-
- startsWith(prefix: string): boolean {
- return this.findNode(prefix) !== null;
- }
-
- private findNode(prefix: string): TrieNode | null {
- let node = this.root;
- for (const char of prefix) {
- if (!node.children.has(char)) {
- return null;
- }
- node = node.children.get(char)!;
- }
- return node;
- }
-
- getWordsWithPrefix(prefix: string): string[] {
- const node = this.findNode(prefix);
- if (!node) return [];
- const words: string[] = [];
- this.collectWords(node, prefix, words);
- return words;
- }
-
- private collectWords(node: TrieNode, prefix: string, words: string[]): void {
- if (node.isEndOfWord) {
- words.push(prefix);
- }
- for (const [char, child] of node.children) {
- this.collectWords(child, prefix + char, words);
- }
- }
- }
-
- it('should insert and search words', () => {
- const trie = new Trie();
- trie.insert('hello');
- trie.insert('help');
- expect(trie.search('hello')).toBe(true);
- expect(trie.search('help')).toBe(true);
- expect(trie.search('hel')).toBe(false);
- });
-
- it('should check prefixes', () => {
- const trie = new Trie();
- trie.insert('hello');
- expect(trie.startsWith('hel')).toBe(true);
- expect(trie.startsWith('hello')).toBe(true);
- expect(trie.startsWith('world')).toBe(false);
- });
-
- it('should get words with prefix', () => {
- const trie = new Trie();
- trie.insert('hello');
- trie.insert('help');
- trie.insert('world');
- const words = trie.getWordsWithPrefix('hel');
- expect(words.sort()).toEqual(['hello', 'help']);
- });
- });
-
- describe('Sorted Array', () => {
- class SortedArray {
- private items: T[] = [];
- private compareFn: (a: T, b: T) => number;
-
- constructor(compareFn: (a: T, b: T) => number = (a: T, b: T) => (a as number) - (b as number)) {
- this.compareFn = compareFn;
- }
-
- insert(item: T): void {
- let left = 0;
- let right = this.items.length;
-
- while (left < right) {
- const mid = Math.floor((left + right) / 2);
- if (this.compareFn(this.items[mid], item) < 0) {
- left = mid + 1;
- } else {
- right = mid;
- }
- }
-
- this.items.splice(left, 0, item);
- }
-
- remove(item: T): boolean {
- const index = this.indexOf(item);
- if (index === -1) return false;
- this.items.splice(index, 1);
- return true;
- }
-
- indexOf(item: T): number {
- let left = 0;
- let right = this.items.length;
-
- while (left < right) {
- const mid = Math.floor((left + right) / 2);
- const cmp = this.compareFn(this.items[mid], item);
- if (cmp === 0) return mid;
- if (cmp < 0) {
- left = mid + 1;
- } else {
- right = mid;
- }
- }
-
- return -1;
- }
-
- has(item: T): boolean {
- return this.indexOf(item) !== -1;
- }
-
- get(index: number): T | undefined {
- return this.items[index];
- }
-
- get size(): number {
- return this.items.length;
- }
-
- toArray(): T[] {
- return [...this.items];
- }
- }
-
- it('should maintain sorted order', () => {
- const arr = new SortedArray();
- arr.insert(5);
- arr.insert(2);
- arr.insert(8);
- arr.insert(1);
- expect(arr.toArray()).toEqual([1, 2, 5, 8]);
- });
-
- it('should find items', () => {
- const arr = new SortedArray();
- arr.insert(1);
- arr.insert(2);
- arr.insert(3);
- expect(arr.has(2)).toBe(true);
- expect(arr.has(4)).toBe(false);
- });
-
- it('should remove items', () => {
- const arr = new SortedArray();
- arr.insert(1);
- arr.insert(2);
- arr.insert(3);
- arr.remove(2);
- expect(arr.toArray()).toEqual([1, 3]);
- });
-
- it('should work with custom comparator', () => {
- const arr = new SortedArray((a, b) => a.localeCompare(b));
- arr.insert('banana');
- arr.insert('apple');
- arr.insert('cherry');
- expect(arr.toArray()).toEqual(['apple', 'banana', 'cherry']);
- });
- });
-
- describe('Counter', () => {
- class Counter {
- private counts: Map = new Map();
-
- increment(key: T, amount: number = 1): number {
- const current = this.counts.get(key) || 0;
- const newCount = current + amount;
- this.counts.set(key, newCount);
- return newCount;
- }
-
- decrement(key: T, amount: number = 1): number {
- return this.increment(key, -amount);
- }
-
- get(key: T): number {
- return this.counts.get(key) || 0;
- }
-
- total(): number {
- let sum = 0;
- for (const count of this.counts.values()) {
- sum += count;
- }
- return sum;
- }
-
- mostCommon(n?: number): [T, number][] {
- const entries = Array.from(this.counts.entries());
- entries.sort((a, b) => b[1] - a[1]);
- return n ? entries.slice(0, n) : entries;
- }
-
- keys(): T[] {
- return Array.from(this.counts.keys());
- }
-
- clear(): void {
- this.counts.clear();
- }
- }
-
- it('should count items', () => {
- const counter = new Counter();
- counter.increment('a');
- counter.increment('a');
- counter.increment('b');
- expect(counter.get('a')).toBe(2);
- expect(counter.get('b')).toBe(1);
- expect(counter.get('c')).toBe(0);
- });
-
- it('should decrement counts', () => {
- const counter = new Counter();
- counter.increment('a', 5);
- counter.decrement('a', 2);
- expect(counter.get('a')).toBe(3);
- });
-
- it('should calculate total', () => {
- const counter = new Counter();
- counter.increment('a', 3);
- counter.increment('b', 2);
- expect(counter.total()).toBe(5);
- });
-
- it('should get most common', () => {
- const counter = new Counter();
- counter.increment('a', 5);
- counter.increment('b', 3);
- counter.increment('c', 8);
- const top2 = counter.mostCommon(2);
- expect(top2[0][0]).toBe('c');
- expect(top2[1][0]).toBe('a');
- });
- });
-
- describe('Bidirectional Map', () => {
- class BiMap {
- private forward: Map = new Map();
- private reverse: Map = new Map();
-
- set(key: K, value: V): void {
- // Remove existing mappings
- if (this.forward.has(key)) {
- this.reverse.delete(this.forward.get(key)!);
- }
- if (this.reverse.has(value)) {
- this.forward.delete(this.reverse.get(value)!);
- }
-
- this.forward.set(key, value);
- this.reverse.set(value, key);
- }
-
- get(key: K): V | undefined {
- return this.forward.get(key);
- }
-
- getKey(value: V): K | undefined {
- return this.reverse.get(value);
- }
-
- hasKey(key: K): boolean {
- return this.forward.has(key);
- }
-
- hasValue(value: V): boolean {
- return this.reverse.has(value);
- }
-
- deleteKey(key: K): boolean {
- const value = this.forward.get(key);
- if (value === undefined) return false;
- this.forward.delete(key);
- this.reverse.delete(value);
- return true;
- }
-
- deleteValue(value: V): boolean {
- const key = this.reverse.get(value);
- if (key === undefined) return false;
- this.reverse.delete(value);
- this.forward.delete(key);
- return true;
- }
-
- get size(): number {
- return this.forward.size;
- }
-
- clear(): void {
- this.forward.clear();
- this.reverse.clear();
- }
- }
-
- it('should map in both directions', () => {
- const bimap = new BiMap();
- bimap.set('one', 1);
- bimap.set('two', 2);
- expect(bimap.get('one')).toBe(1);
- expect(bimap.getKey(2)).toBe('two');
- });
-
- it('should check existence in both directions', () => {
- const bimap = new BiMap();
- bimap.set('one', 1);
- expect(bimap.hasKey('one')).toBe(true);
- expect(bimap.hasValue(1)).toBe(true);
- expect(bimap.hasKey('two')).toBe(false);
- expect(bimap.hasValue(2)).toBe(false);
- });
-
- it('should delete in both directions', () => {
- const bimap = new BiMap();
- bimap.set('one', 1);
- bimap.deleteKey('one');
- expect(bimap.hasKey('one')).toBe(false);
- expect(bimap.hasValue(1)).toBe(false);
- });
-
- it('should override existing mappings', () => {
- const bimap = new BiMap();
- bimap.set('one', 1);
- bimap.set('one', 2); // Override value
- expect(bimap.get('one')).toBe(2);
- expect(bimap.hasValue(1)).toBe(false);
- });
- });
-
- describe('Default Map', () => {
- class DefaultMap {
- private map: Map = new Map();
- private defaultFn: () => V;
-
- constructor(defaultFn: () => V) {
- this.defaultFn = defaultFn;
- }
-
- get(key: K): V {
- if (!this.map.has(key)) {
- this.map.set(key, this.defaultFn());
- }
- return this.map.get(key)!;
- }
-
- set(key: K, value: V): void {
- this.map.set(key, value);
- }
-
- has(key: K): boolean {
- return this.map.has(key);
- }
-
- delete(key: K): boolean {
- return this.map.delete(key);
- }
-
- get size(): number {
- return this.map.size;
- }
-
- keys(): K[] {
- return Array.from(this.map.keys());
- }
-
- values(): V[] {
- return Array.from(this.map.values());
- }
- }
-
- it('should return default value for missing keys', () => {
- const map = new DefaultMap(() => []);
- map.get('list').push(1);
- map.get('list').push(2);
- expect(map.get('list')).toEqual([1, 2]);
- });
-
- it('should return existing value', () => {
- const map = new DefaultMap(() => 0);
- map.set('key', 5);
- expect(map.get('key')).toBe(5);
- });
-
- it('should create new default for each missing key', () => {
- const map = new DefaultMap(() => []);
- map.get('a').push(1);
- map.get('b').push(2);
- expect(map.get('a')).toEqual([1]);
- expect(map.get('b')).toEqual([2]);
- });
- });
-
- describe('MultiMap', () => {
- class MultiMap {
- private map: Map = new Map();
-
- add(key: K, value: V): void {
- if (!this.map.has(key)) {
- this.map.set(key, []);
- }
- this.map.get(key)!.push(value);
- }
-
- get(key: K): V[] {
- return this.map.get(key) || [];
- }
-
- has(key: K): boolean {
- return this.map.has(key);
- }
-
- delete(key: K): boolean {
- return this.map.delete(key);
- }
-
- removeValue(key: K, value: V): boolean {
- const values = this.map.get(key);
- if (!values) return false;
- const index = values.indexOf(value);
- if (index === -1) return false;
- values.splice(index, 1);
- if (values.length === 0) {
- this.map.delete(key);
- }
- return true;
- }
-
- getValueCount(key: K): number {
- return this.map.get(key)?.length || 0;
- }
-
- get size(): number {
- return this.map.size;
- }
-
- get totalValues(): number {
- let count = 0;
- for (const values of this.map.values()) {
- count += values.length;
- }
- return count;
- }
- }
-
- it('should store multiple values per key', () => {
- const mm = new MultiMap();
- mm.add('key', 1);
- mm.add('key', 2);
- mm.add('key', 3);
- expect(mm.get('key')).toEqual([1, 2, 3]);
- });
-
- it('should remove specific values', () => {
- const mm = new MultiMap();
- mm.add('key', 1);
- mm.add('key', 2);
- mm.removeValue('key', 1);
- expect(mm.get('key')).toEqual([2]);
- });
-
- it('should count values per key', () => {
- const mm = new MultiMap();
- mm.add('a', 1);
- mm.add('a', 2);
- mm.add('b', 3);
- expect(mm.getValueCount('a')).toBe(2);
- expect(mm.getValueCount('b')).toBe(1);
- expect(mm.getValueCount('c')).toBe(0);
- });
-
- it('should count total values', () => {
- const mm = new MultiMap();
- mm.add('a', 1);
- mm.add('a', 2);
- mm.add('b', 3);
- expect(mm.totalValues).toBe(3);
- });
- });
-});
diff --git a/test/e2e/e2e.config.ts b/test/e2e/e2e.config.ts
index c5364ff7..ec70d670 100644
--- a/test/e2e/e2e.config.ts
+++ b/test/e2e/e2e.config.ts
@@ -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
diff --git a/test/e2e/fixtures/index.ts b/test/e2e/fixtures/index.ts
index 5ac875b8..23543624 100644
--- a/test/e2e/fixtures/index.ts
+++ b/test/e2e/fixtures/index.ts
@@ -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';
diff --git a/test/e2e/fixtures/mobile-browser.fixture.ts b/test/e2e/fixtures/mobile-browser.fixture.ts
new file mode 100644
index 00000000..195ea356
--- /dev/null
+++ b/test/e2e/fixtures/mobile-browser.fixture.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const minHeightStr = await getComputedStyle(page, selector, 'min-height');
+ return parseInt(minHeightStr) || 0;
+}
diff --git a/test/e2e/workflows/agent-interactions.e2e.ts b/test/e2e/workflows/agent-interactions.e2e.ts
index 4b265289..bcf450cd 100644
--- a/test/e2e/workflows/agent-interactions.e2e.ts
+++ b/test/e2e/workflows/agent-interactions.e2e.ts
@@ -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');
diff --git a/test/e2e/workflows/input-interactions.e2e.ts b/test/e2e/workflows/input-interactions.e2e.ts
index 6c7efff8..02834cc0 100644
--- a/test/e2e/workflows/input-interactions.e2e.ts
+++ b/test/e2e/workflows/input-interactions.e2e.ts
@@ -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) {
diff --git a/test/e2e/workflows/mobile-safari.e2e.ts b/test/e2e/workflows/mobile-safari.e2e.ts
new file mode 100644
index 00000000..e1682bb6
--- /dev/null
+++ b/test/e2e/workflows/mobile-safari.e2e.ts
@@ -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);
+});
diff --git a/test/e2e/workflows/multi-session.e2e.ts b/test/e2e/workflows/multi-session.e2e.ts
index b02baac2..8dec3473 100644
--- a/test/e2e/workflows/multi-session.e2e.ts
+++ b/test/e2e/workflows/multi-session.e2e.ts
@@ -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)');
diff --git a/test/e2e/workflows/quick-start.e2e.ts b/test/e2e/workflows/quick-start.e2e.ts
index 00962e3c..d14883d7 100644
--- a/test/e2e/workflows/quick-start.e2e.ts
+++ b/test/e2e/workflows/quick-start.e2e.ts
@@ -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 {
diff --git a/test/e2e/workflows/respawn-flow.e2e.ts b/test/e2e/workflows/respawn-flow.e2e.ts
index d7151e52..af918760 100644
--- a/test/e2e/workflows/respawn-flow.e2e.ts
+++ b/test/e2e/workflows/respawn-flow.e2e.ts
@@ -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
diff --git a/test/e2e/workflows/session-delete.e2e.ts b/test/e2e/workflows/session-delete.e2e.ts
index d3a4b348..2d655b24 100644
--- a/test/e2e/workflows/session-delete.e2e.ts
+++ b/test/e2e/workflows/session-delete.e2e.ts
@@ -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
diff --git a/test/e2e/workflows/session-input.e2e.ts b/test/e2e/workflows/session-input.e2e.ts
index 7a416163..c1f09087 100644
--- a/test/e2e/workflows/session-input.e2e.ts
+++ b/test/e2e/workflows/session-input.e2e.ts
@@ -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 {
diff --git a/test/ralph-config.extended.test.ts b/test/ralph-config.extended.test.ts
deleted file mode 100644
index 029f2cad..00000000
--- a/test/ralph-config.extended.test.ts
+++ /dev/null
@@ -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 => {
- const result: Record = {};
- 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(/([A-Z0-9_-]+)<\/promise>/);
- return match ? match[1] : null;
- };
-
- it('should extract simple promise tag', () => {
- expect(extractPromiseTag('COMPLETE')).toBe('COMPLETE');
- });
-
- it('should extract promise tag with underscores', () => {
- expect(extractPromiseTag('ALL_DONE')).toBe('ALL_DONE');
- });
-
- it('should extract promise tag with hyphens', () => {
- expect(extractPromiseTag('TESTS-PASS')).toBe('TESTS-PASS');
- });
-
- it('should extract promise tag with numbers', () => {
- expect(extractPromiseTag('TASK123')).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('lowercase')).toBeNull();
- expect(extractPromiseTag('')).toBeNull();
- });
-
- it('should handle embedded promise tags', () => {
- const content = 'Text before EMBEDDED text after';
- expect(extractPromiseTag(content)).toBe('EMBEDDED');
- });
-
- it('should handle multiline content', () => {
- const content = `
-Line 1
-Line 2 with MULTILINE
-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*[`"']?([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: \`COMPLETE\`
-
-### 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: "DOUBLE"`;
-
- const content2 = `## Ralph Wiggum Loop
-Completion phrase: 'SINGLE'`;
-
- 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): { 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);
- });
- });
-});
diff --git a/test/setup.ts b/test/setup.ts
index fe277e31..8ebbedcd 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -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...');