mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1443
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+167
-35
@@ -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() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<title>Claudeman</title>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Mobile browser fixture for E2E tests
|
||||
* Manages Playwright WebKit browser lifecycle with mobile viewport emulation
|
||||
*/
|
||||
|
||||
import { webkit, Browser, BrowserContext, Page } from 'playwright';
|
||||
import { MOBILE_VIEWPORTS } from '../e2e.config.js';
|
||||
|
||||
export interface MobileBrowserFixture {
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
}
|
||||
|
||||
export interface MobileViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor: number;
|
||||
isMobile: boolean;
|
||||
hasTouch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and launch a mobile Safari (WebKit) browser fixture
|
||||
* Defaults to iPhone 17 Pro viewport
|
||||
* @param viewport - Optional viewport configuration (defaults to iPhone 17 Pro)
|
||||
* @returns MobileBrowserFixture with browser, context, and page
|
||||
*/
|
||||
export async function createMobileSafariFixture(
|
||||
viewport: MobileViewport = MOBILE_VIEWPORTS.IPHONE_17_PRO
|
||||
): Promise<MobileBrowserFixture> {
|
||||
const browser = await webkit.launch({
|
||||
headless: true,
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: viewport.deviceScaleFactor,
|
||||
isMobile: viewport.isMobile,
|
||||
hasTouch: viewport.hasTouch,
|
||||
ignoreHTTPSErrors: true,
|
||||
// Safari-like user agent
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
// Set default timeout for all operations
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
return {
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup a mobile browser fixture
|
||||
* @param fixture - Mobile browser fixture to destroy
|
||||
*/
|
||||
export async function destroyMobileBrowserFixture(fixture: MobileBrowserFixture): Promise<void> {
|
||||
if (fixture.page) {
|
||||
await fixture.page.close().catch(() => {});
|
||||
}
|
||||
if (fixture.context) {
|
||||
await fixture.context.close().catch(() => {});
|
||||
}
|
||||
if (fixture.browser) {
|
||||
await fixture.browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a tap gesture (touch start + touch end)
|
||||
* @param page - Playwright page
|
||||
* @param selector - CSS selector or x,y coordinates
|
||||
*/
|
||||
export async function tap(page: Page, selectorOrCoords: string | { x: number; y: number }): Promise<void> {
|
||||
if (typeof selectorOrCoords === 'string') {
|
||||
await page.tap(selectorOrCoords);
|
||||
} else {
|
||||
await page.touchscreen.tap(selectorOrCoords.x, selectorOrCoords.y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a swipe gesture
|
||||
* @param page - Playwright page
|
||||
* @param startX - Starting X coordinate
|
||||
* @param startY - Starting Y coordinate
|
||||
* @param endX - Ending X coordinate
|
||||
* @param endY - Ending Y coordinate
|
||||
* @param duration - Duration in ms (default 100ms for fast swipe)
|
||||
*/
|
||||
export async function swipe(
|
||||
page: Page,
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
duration = 100
|
||||
): Promise<void> {
|
||||
// Calculate steps based on duration (roughly 16ms per step for 60fps)
|
||||
const steps = Math.max(Math.floor(duration / 16), 2);
|
||||
|
||||
// Start touch
|
||||
await page.touchscreen.tap(startX, startY);
|
||||
|
||||
// Move through intermediate points
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const ratio = i / steps;
|
||||
const x = startX + (endX - startX) * ratio;
|
||||
const y = startY + (endY - startY) * ratio;
|
||||
await page.touchscreen.tap(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a horizontal swipe (for tab switching)
|
||||
* @param page - Playwright page
|
||||
* @param direction - 'left' or 'right'
|
||||
* @param distance - Swipe distance in pixels (default 100)
|
||||
*/
|
||||
export async function swipeHorizontal(
|
||||
page: Page,
|
||||
direction: 'left' | 'right',
|
||||
distance = 100
|
||||
): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) return;
|
||||
|
||||
const centerX = viewport.width / 2;
|
||||
const centerY = viewport.height / 2;
|
||||
|
||||
if (direction === 'left') {
|
||||
await swipe(page, centerX + distance / 2, centerY, centerX - distance / 2, centerY);
|
||||
} else {
|
||||
await swipe(page, centerX - distance / 2, centerY, centerX + distance / 2, centerY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a long press gesture
|
||||
* @param page - Playwright page
|
||||
* @param selector - CSS selector
|
||||
* @param duration - Hold duration in ms (default 500ms)
|
||||
*/
|
||||
export async function longPress(page: Page, selector: string, duration = 500): Promise<void> {
|
||||
const element = await page.$(selector);
|
||||
if (!element) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
const box = await element.boundingBox();
|
||||
if (!box) throw new Error(`Element has no bounding box: ${selector}`);
|
||||
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
|
||||
// Simulate long press with mouse (touch events are more complex)
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(duration);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get computed style property for an element
|
||||
* @param page - Playwright page
|
||||
* @param selector - CSS selector
|
||||
* @param property - CSS property name
|
||||
* @returns The computed style value
|
||||
*/
|
||||
export async function getComputedStyle(
|
||||
page: Page,
|
||||
selector: string,
|
||||
property: string
|
||||
): Promise<string> {
|
||||
return await page.$eval(
|
||||
selector,
|
||||
(el, prop) => window.getComputedStyle(el).getPropertyValue(prop),
|
||||
property
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if body has a specific class
|
||||
* @param page - Playwright page
|
||||
* @param className - Class name to check
|
||||
* @returns true if class exists
|
||||
*/
|
||||
export async function hasBodyClass(page: Page, className: string): Promise<boolean> {
|
||||
return await page.evaluate((cls) => document.body.classList.contains(cls), className);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum height of an element
|
||||
* @param page - Playwright page
|
||||
* @param selector - CSS selector
|
||||
* @returns Minimum height in pixels
|
||||
*/
|
||||
export async function getMinHeight(page: Page, selector: string): Promise<number> {
|
||||
const minHeightStr = await getComputedStyle(page, selector, 'min-height');
|
||||
return parseInt(minHeightStr) || 0;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Extended tests for ralph-config module
|
||||
*
|
||||
* Additional comprehensive tests for parsing Ralph Loop configuration
|
||||
* from various file formats.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Ralph Config Parsing Extended', () => {
|
||||
// Helper function to simulate YAML frontmatter parsing
|
||||
const parseYamlFrontmatter = (content: string): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return result;
|
||||
|
||||
const lines = frontmatterMatch[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([a-z-]+):\s*(.+)$/i);
|
||||
if (match) {
|
||||
const key = match[1].toLowerCase().replace(/-/g, '_');
|
||||
let value: any = match[2].trim();
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
// Parse booleans
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
// Parse numbers
|
||||
else if (/^\d+$/.test(value)) value = parseInt(value, 10);
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('YAML Frontmatter Parsing', () => {
|
||||
it('should parse basic frontmatter', () => {
|
||||
const content = `---
|
||||
enabled: true
|
||||
iteration: 5
|
||||
max-iterations: 50
|
||||
completion-promise: "COMPLETE"
|
||||
---
|
||||
# Content here`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.iteration).toBe(5);
|
||||
expect(config.max_iterations).toBe(50);
|
||||
expect(config.completion_promise).toBe('COMPLETE');
|
||||
});
|
||||
|
||||
it('should handle single-quoted values', () => {
|
||||
const content = `---
|
||||
completion-promise: 'SINGLE_QUOTED'
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.completion_promise).toBe('SINGLE_QUOTED');
|
||||
});
|
||||
|
||||
it('should handle unquoted values', () => {
|
||||
const content = `---
|
||||
completion-promise: UNQUOTED
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.completion_promise).toBe('UNQUOTED');
|
||||
});
|
||||
|
||||
it('should parse boolean false', () => {
|
||||
const content = `---
|
||||
enabled: false
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing frontmatter', () => {
|
||||
const content = `# No frontmatter here
|
||||
Just regular content`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty frontmatter', () => {
|
||||
const content = `---
|
||||
---
|
||||
# Empty frontmatter`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle various iteration values', () => {
|
||||
for (const iteration of [0, 1, 10, 100, 1000]) {
|
||||
const content = `---
|
||||
iteration: ${iteration}
|
||||
---`;
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.iteration).toBe(iteration);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle various max-iterations values', () => {
|
||||
for (const maxIter of [10, 50, 100, 500, 1000]) {
|
||||
const content = `---
|
||||
max-iterations: ${maxIter}
|
||||
---`;
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.max_iterations).toBe(maxIter);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle completion phrases with hyphens', () => {
|
||||
const content = `---
|
||||
completion-promise: "TESTS-PASS"
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.completion_promise).toBe('TESTS-PASS');
|
||||
});
|
||||
|
||||
it('should handle completion phrases with underscores', () => {
|
||||
const content = `---
|
||||
completion-promise: "ALL_TASKS_DONE"
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.completion_promise).toBe('ALL_TASKS_DONE');
|
||||
});
|
||||
|
||||
it('should handle completion phrases with numbers', () => {
|
||||
const content = `---
|
||||
completion-promise: "TASK_123_COMPLETE"
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.completion_promise).toBe('TASK_123_COMPLETE');
|
||||
});
|
||||
|
||||
it('should handle lowercase keys', () => {
|
||||
const content = `---
|
||||
enabled: true
|
||||
iteration: 5
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.iteration).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle extra whitespace', () => {
|
||||
const content = `---
|
||||
enabled: true
|
||||
iteration: 5
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.iteration).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle mixed case values (booleans)', () => {
|
||||
const content = `---
|
||||
enabled: true
|
||||
---`;
|
||||
|
||||
const config = parseYamlFrontmatter(content);
|
||||
expect(config.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Promise Tag Extraction', () => {
|
||||
const extractPromiseTag = (content: string): string | null => {
|
||||
const match = content.match(/<promise>([A-Z0-9_-]+)<\/promise>/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
it('should extract simple promise tag', () => {
|
||||
expect(extractPromiseTag('<promise>COMPLETE</promise>')).toBe('COMPLETE');
|
||||
});
|
||||
|
||||
it('should extract promise tag with underscores', () => {
|
||||
expect(extractPromiseTag('<promise>ALL_DONE</promise>')).toBe('ALL_DONE');
|
||||
});
|
||||
|
||||
it('should extract promise tag with hyphens', () => {
|
||||
expect(extractPromiseTag('<promise>TESTS-PASS</promise>')).toBe('TESTS-PASS');
|
||||
});
|
||||
|
||||
it('should extract promise tag with numbers', () => {
|
||||
expect(extractPromiseTag('<promise>TASK123</promise>')).toBe('TASK123');
|
||||
});
|
||||
|
||||
it('should return null for missing tag', () => {
|
||||
expect(extractPromiseTag('No promise tag here')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for malformed tags', () => {
|
||||
expect(extractPromiseTag('<promise>lowercase</promise>')).toBeNull();
|
||||
expect(extractPromiseTag('<promise></promise>')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle embedded promise tags', () => {
|
||||
const content = 'Text before <promise>EMBEDDED</promise> text after';
|
||||
expect(extractPromiseTag(content)).toBe('EMBEDDED');
|
||||
});
|
||||
|
||||
it('should handle multiline content', () => {
|
||||
const content = `
|
||||
Line 1
|
||||
Line 2 with <promise>MULTILINE</promise>
|
||||
Line 3
|
||||
`;
|
||||
expect(extractPromiseTag(content)).toBe('MULTILINE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAUDE.md Parsing', () => {
|
||||
const extractFromClaudeMd = (content: string): { phrase: string | null; found: boolean } => {
|
||||
// Look for completion phrase in Ralph Loop section
|
||||
const ralphSectionMatch = content.match(/## Ralph Wiggum Loop[\s\S]*?(?=##|$)/);
|
||||
if (!ralphSectionMatch) {
|
||||
return { phrase: null, found: false };
|
||||
}
|
||||
|
||||
const section = ralphSectionMatch[0];
|
||||
const promiseMatch = section.match(/[Cc]ompletion [Pp]hrase:?\s*[`"']?<promise>([A-Z0-9_-]+)<\/promise>[`"']?/);
|
||||
|
||||
return {
|
||||
phrase: promiseMatch ? promiseMatch[1] : null,
|
||||
found: true,
|
||||
};
|
||||
};
|
||||
|
||||
it('should extract phrase from Ralph Wiggum Loop section', () => {
|
||||
const content = `
|
||||
## Ralph Wiggum Loop
|
||||
|
||||
Completion Phrase: \`<promise>COMPLETE</promise>\`
|
||||
|
||||
### How to use
|
||||
...
|
||||
`;
|
||||
const result = extractFromClaudeMd(content);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.phrase).toBe('COMPLETE');
|
||||
});
|
||||
|
||||
it('should handle different quote styles', () => {
|
||||
const content1 = `## Ralph Wiggum Loop
|
||||
Completion phrase: "<promise>DOUBLE</promise>"`;
|
||||
|
||||
const content2 = `## Ralph Wiggum Loop
|
||||
Completion phrase: '<promise>SINGLE</promise>'`;
|
||||
|
||||
expect(extractFromClaudeMd(content1).phrase).toBe('DOUBLE');
|
||||
expect(extractFromClaudeMd(content2).phrase).toBe('SINGLE');
|
||||
});
|
||||
|
||||
it('should return null if section not found', () => {
|
||||
const content = `
|
||||
## Different Section
|
||||
|
||||
Some content
|
||||
`;
|
||||
const result = extractFromClaudeMd(content);
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.phrase).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle Ralph Wiggum Loop section without phrase', () => {
|
||||
const content = `
|
||||
## Ralph Wiggum Loop
|
||||
|
||||
Just some text without a completion phrase
|
||||
`;
|
||||
const result = extractFromClaudeMd(content);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.phrase).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Priority', () => {
|
||||
it('should prioritize ralph-loop.local.md over CLAUDE.md', () => {
|
||||
const localConfig = { completion_promise: 'FROM_LOCAL' };
|
||||
const claudeConfig = { phrase: 'FROM_CLAUDE' };
|
||||
|
||||
// Priority: local > claude
|
||||
const finalPhrase = localConfig.completion_promise || claudeConfig.phrase;
|
||||
expect(finalPhrase).toBe('FROM_LOCAL');
|
||||
});
|
||||
|
||||
it('should fallback to CLAUDE.md when local is missing', () => {
|
||||
const localConfig = {};
|
||||
const claudeConfig = { phrase: 'FROM_CLAUDE' };
|
||||
|
||||
const finalPhrase = (localConfig as any).completion_promise || claudeConfig.phrase;
|
||||
expect(finalPhrase).toBe('FROM_CLAUDE');
|
||||
});
|
||||
|
||||
it('should handle both missing', () => {
|
||||
const localConfig = {};
|
||||
const claudeConfig = { phrase: null };
|
||||
|
||||
const finalPhrase = (localConfig as any).completion_promise || claudeConfig.phrase || null;
|
||||
expect(finalPhrase).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Iteration and Max Iterations', () => {
|
||||
it('should handle iteration at start', () => {
|
||||
const config = { iteration: 0, max_iterations: 50 };
|
||||
expect(config.iteration).toBe(0);
|
||||
expect(config.iteration < config.max_iterations).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle iteration at end', () => {
|
||||
const config = { iteration: 50, max_iterations: 50 };
|
||||
expect(config.iteration).toBe(50);
|
||||
expect(config.iteration >= config.max_iterations).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle no max iterations', () => {
|
||||
const config = { iteration: 5, max_iterations: null };
|
||||
expect(config.max_iterations).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate progress percentage', () => {
|
||||
const calculateProgress = (current: number, max: number | null): number | null => {
|
||||
if (max === null || max === 0) return null;
|
||||
return Math.round((current / max) * 100);
|
||||
};
|
||||
|
||||
expect(calculateProgress(5, 50)).toBe(10);
|
||||
expect(calculateProgress(25, 50)).toBe(50);
|
||||
expect(calculateProgress(50, 50)).toBe(100);
|
||||
expect(calculateProgress(0, 50)).toBe(0);
|
||||
expect(calculateProgress(5, null)).toBeNull();
|
||||
expect(calculateProgress(5, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Path Validation', () => {
|
||||
const isValidRalphConfigPath = (path: string): boolean => {
|
||||
return path.endsWith('.claude/ralph-loop.local.md') ||
|
||||
path.endsWith('.claude/ralph-loop.md') ||
|
||||
path.endsWith('CLAUDE.md');
|
||||
};
|
||||
|
||||
it('should accept valid paths', () => {
|
||||
expect(isValidRalphConfigPath('/project/.claude/ralph-loop.local.md')).toBe(true);
|
||||
expect(isValidRalphConfigPath('/project/.claude/ralph-loop.md')).toBe(true);
|
||||
expect(isValidRalphConfigPath('/project/CLAUDE.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid paths', () => {
|
||||
expect(isValidRalphConfigPath('/project/random.md')).toBe(false);
|
||||
expect(isValidRalphConfigPath('/project/.claude/other.md')).toBe(false);
|
||||
expect(isValidRalphConfigPath('/project/claude.md')).toBe(false); // lowercase
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enabled State Detection', () => {
|
||||
const isRalphEnabled = (config: { enabled?: boolean; iteration?: number; max_iterations?: number }): boolean => {
|
||||
// Enabled if explicitly true, or if there's iteration data
|
||||
return config.enabled === true ||
|
||||
(typeof config.iteration === 'number' && config.iteration > 0) ||
|
||||
(typeof config.max_iterations === 'number' && config.max_iterations > 0);
|
||||
};
|
||||
|
||||
it('should detect enabled=true', () => {
|
||||
expect(isRalphEnabled({ enabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect enabled=false', () => {
|
||||
expect(isRalphEnabled({ enabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect enabled from iteration', () => {
|
||||
expect(isRalphEnabled({ iteration: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect enabled from max_iterations', () => {
|
||||
expect(isRalphEnabled({ max_iterations: 50 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty config', () => {
|
||||
expect(isRalphEnabled({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle zero iteration', () => {
|
||||
expect(isRalphEnabled({ iteration: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Validation', () => {
|
||||
const validateConfig = (config: Record<string, any>): { valid: boolean; errors: string[] } => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (config.enabled !== undefined && typeof config.enabled !== 'boolean') {
|
||||
errors.push('enabled must be a boolean');
|
||||
}
|
||||
|
||||
if (config.iteration !== undefined) {
|
||||
if (typeof config.iteration !== 'number' || config.iteration < 0) {
|
||||
errors.push('iteration must be a non-negative number');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.max_iterations !== undefined) {
|
||||
if (typeof config.max_iterations !== 'number' || config.max_iterations < 1) {
|
||||
errors.push('max_iterations must be a positive number');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.completion_promise !== undefined) {
|
||||
if (typeof config.completion_promise !== 'string' || !/^[A-Z0-9_-]+$/.test(config.completion_promise)) {
|
||||
errors.push('completion_promise must be uppercase alphanumeric with hyphens/underscores');
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
};
|
||||
|
||||
it('should validate correct config', () => {
|
||||
const result = validateConfig({
|
||||
enabled: true,
|
||||
iteration: 5,
|
||||
max_iterations: 50,
|
||||
completion_promise: 'COMPLETE',
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should catch invalid enabled', () => {
|
||||
const result = validateConfig({ enabled: 'yes' });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('enabled must be a boolean');
|
||||
});
|
||||
|
||||
it('should catch negative iteration', () => {
|
||||
const result = validateConfig({ iteration: -1 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('iteration must be a non-negative number');
|
||||
});
|
||||
|
||||
it('should catch zero max_iterations', () => {
|
||||
const result = validateConfig({ max_iterations: 0 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('max_iterations must be a positive number');
|
||||
});
|
||||
|
||||
it('should catch lowercase completion_promise', () => {
|
||||
const result = validateConfig({ completion_promise: 'lowercase' });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('completion_promise must be uppercase alphanumeric with hyphens/underscores');
|
||||
});
|
||||
|
||||
it('should allow empty config', () => {
|
||||
const result = validateConfig({});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -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...');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user