From 2a8882a94b57d8077b02c3e37f3a7684fc7d3626 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 30 Jan 2026 22:25:59 +0100 Subject: [PATCH] chore: bump version to 0.1443 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- package.json | 2 +- src/web/public/app.js | 202 +++- src/web/public/index.html | 2 +- src/web/public/styles.css | 349 ++++++ test/data-structures.test.ts | 1062 ------------------ test/e2e/e2e.config.ts | 29 + test/e2e/fixtures/index.ts | 14 + test/e2e/fixtures/mobile-browser.fixture.ts | 204 ++++ test/e2e/workflows/agent-interactions.e2e.ts | 8 +- test/e2e/workflows/input-interactions.e2e.ts | 6 +- test/e2e/workflows/mobile-safari.e2e.ts | 265 +++++ test/e2e/workflows/multi-session.e2e.ts | 8 +- test/e2e/workflows/quick-start.e2e.ts | 10 +- test/e2e/workflows/respawn-flow.e2e.ts | 10 +- test/e2e/workflows/session-delete.e2e.ts | 12 +- test/e2e/workflows/session-input.e2e.ts | 12 +- test/ralph-config.extended.test.ts | 472 -------- test/setup.ts | 8 +- 19 files changed, 1071 insertions(+), 1606 deletions(-) delete mode 100644 test/data-structures.test.ts create mode 100644 test/e2e/fixtures/mobile-browser.fixture.ts create mode 100644 test/e2e/workflows/mobile-safari.e2e.ts delete mode 100644 test/ralph-config.extended.test.ts 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...');