diff --git a/scripts/remotion/components/TerminalScreen.tsx b/scripts/remotion/components/TerminalScreen.tsx deleted file mode 100644 index 1ae79cf5..00000000 --- a/scripts/remotion/components/TerminalScreen.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import { useCurrentFrame } from 'remotion'; -import { colors, fonts } from '../lib/theme'; - -type OverlayChar = { - char: string; - confirmed: boolean; -}; - -type TerminalScreenProps = { - typed: string; - cursorVisible: boolean; - overlayChars?: OverlayChar[]; - fontSize?: number; -}; - -/** - * Renders a terminal area styled exactly like the real Codeman xterm.js terminal. - * Background #0d0d0d, Fira Code font, block cursor, Claude Code prompt. - */ -export const TerminalScreen: React.FC = ({ - typed, - cursorVisible, - overlayChars, - fontSize = 28, -}) => { - const frame = useCurrentFrame(); - - // Block cursor — solid, no blink (matches Codeman's cursorBlink: false) - const cursorOn = cursorVisible; - // But add a subtle blink for video clarity so viewers notice it - const cursorOpacity = cursorOn ? (Math.floor(frame / 20) % 2 === 0 ? 0.9 : 0.6) : 0; - - const lineHeight = Math.round(fontSize * 1.35); - const charWidth = fontSize * 0.6; - - return ( -
- {/* Previous terminal output lines (fake history for realism) */} -
-
- ❯ - claude --dangerously-skip-permissions -
-
- ╭────────────────────────────────────╮ -
-
- │ Claude Code session active │ -
-
- ╰────────────────────────────────────╯ -
-
- - {/* Active prompt line — this is where the typing happens */} -
- {/* Prompt character */} - ❯ - - {/* Typed text */} - {overlayChars ? ( - // Zerolag mode: show overlay chars with confirmed/unconfirmed color - overlayChars.map((oc, i) => ( - - {oc.char} - - )) - ) : ( - {typed} - )} - - {/* Block cursor */} - -
-
- ); -}; diff --git a/test/input-echo-test.mjs b/test/input-echo-test.mjs deleted file mode 100644 index 322fa580..00000000 --- a/test/input-echo-test.mjs +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Input echo test: Verifies that typing in the web UI doesn't produce - * "visible below" artifacts (text mirrored onto Ink's status bar rows). - * - * Test approach: - * 1. Creates a SHELL session (bash, not Claude) to avoid Ink complexity - * 2. Types various input patterns and captures terminal state - * 3. Verifies echo appears at correct cursor position, not duplicated - * - * Usage: node test/input-echo-test.mjs [--live] - * --live Use existing sessions instead of creating new ones - */ -import { chromium } from 'playwright'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; - -async function createShellSession() { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'shell', name: 'input-test' }) - }); - if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForSessionReady(sessionId, timeoutMs = 10000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const resp = await fetch(`${BASE}/api/sessions/${sessionId}`); - if (resp.ok) { - const data = await resp.json(); - if (data.status === 'idle' || data.status === 'busy') return true; - } - await new Promise(r => setTimeout(r, 200)); - } - return false; -} - -async function getTerminalContent(page) { - return page.evaluate(() => { - const app = window.app; - if (!app?.terminal) return null; - const buf = app.terminal.buffer.active; - const lines = []; - for (let i = 0; i <= buf.baseY + buf.viewportY + app.terminal.rows; i++) { - const line = buf.getLine(i); - if (line) lines.push(line.translateToString(true)); - } - return { - lines, - cursorX: buf.cursorX, - cursorY: buf.cursorY, - rows: app.terminal.rows, - cols: app.terminal.cols, - }; - }); -} - -async function main() { - const isLive = process.argv.includes('--live'); - let sessionId = null; - - console.log('=== Input Echo Test ===\n'); - - // Create a shell session for testing - if (!isLive) { - console.log('Creating shell session...'); - sessionId = await createShellSession(); - console.log(`Session created: ${sessionId}`); - - const ready = await waitForSessionReady(sessionId); - if (!ready) { - console.error('Session failed to become ready'); - await deleteSession(sessionId); - process.exit(1); - } - console.log('Session ready\n'); - } - - const browser = await chromium.launch({ - headless: true, - args: ['--window-size=1280,800'] - }); - const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); - const page = await context.newPage(); - - // Collect console output for debugging - const consoleLogs = []; - page.on('console', msg => consoleLogs.push(`[${msg.type()}] ${msg.text()}`)); - - try { - console.log('Opening Codeman...'); - await page.goto(BASE); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2000); - - // Switch to our test session if not live - if (sessionId) { - // Click the test session tab - const tabClicked = await page.evaluate((id) => { - const tabs = document.querySelectorAll('.session-tab'); - for (const tab of tabs) { - if (tab.dataset.sessionId === id) { - tab.click(); - return true; - } - } - return false; - }, sessionId); - if (tabClicked) { - await page.waitForTimeout(1000); - console.log('Switched to test session'); - } - } - - // Focus the terminal - await page.click('.xterm-screen'); - await page.waitForTimeout(500); - - // Take before screenshot - await page.screenshot({ path: '/tmp/input-test-before.png' }); - console.log('Screenshot: /tmp/input-test-before.png (before typing)'); - - // Get terminal state before typing - const beforeState = await getTerminalContent(page); - const beforeCursorY = beforeState?.cursorY ?? -1; - console.log(`Before: cursor at row ${beforeCursorY}, col ${beforeState?.cursorX}`); - - // TEST 1: Type a short string - console.log('\n--- Test 1: Short string ---'); - const testStr = 'echo hello-world-test-123'; - await page.keyboard.type(testStr, { delay: 30 }); - await page.waitForTimeout(600); // Wait for PTY echo round-trip - - const afterShort = await getTerminalContent(page); - await page.screenshot({ path: '/tmp/input-test-short.png' }); - console.log('Screenshot: /tmp/input-test-short.png'); - - // Check: cursor should still be on same row (or +1 for wrap) - // and text should appear at/near the cursor position - const cursorRowAfter = afterShort?.cursorY ?? -1; - console.log(`After: cursor at row ${cursorRowAfter}, col ${afterShort?.cursorX}`); - - // The echo should be on the cursor's row, not below - const echoLine = afterShort?.lines?.[afterShort.cursorY + (afterShort.lines.length - afterShort.rows)] ?? ''; - console.log(`Echo line: "${echoLine.trim()}"`); - if (echoLine.includes(testStr)) { - console.log('PASS: Echo found at cursor row'); - } else { - console.log('INFO: Echo may be on a different row (check screenshots)'); - } - - // TEST 2: Press Enter and check response - console.log('\n--- Test 2: Enter key ---'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - - const afterEnter = await getTerminalContent(page); - await page.screenshot({ path: '/tmp/input-test-enter.png' }); - console.log('Screenshot: /tmp/input-test-enter.png'); - - // Check for "hello-world-test-123" in the output - const allText = afterEnter?.lines?.join('\n') ?? ''; - if (allText.includes('hello-world-test-123')) { - console.log('PASS: Command output visible'); - } else { - console.log('WARN: Command output not found'); - } - - // TEST 3: Type a LONG string that approaches right edge - console.log('\n--- Test 3: Long string (near right edge) ---'); - const longStr = 'echo ' + 'A'.repeat(afterShort?.cols ? afterShort.cols - 10 : 150); - await page.keyboard.type(longStr, { delay: 10 }); - await page.waitForTimeout(800); - - const afterLong = await getTerminalContent(page); - await page.screenshot({ path: '/tmp/input-test-long.png' }); - console.log('Screenshot: /tmp/input-test-long.png'); - console.log(`After long: cursor at row ${afterLong?.cursorY}, col ${afterLong?.cursorX}`); - - // Check that no text leaked below the cursor row - const cursorAbsRow = (afterLong?.lines?.length ?? 0) - (afterLong?.rows ?? 0) + (afterLong?.cursorY ?? 0); - const rowsBelow = afterLong?.lines?.slice(cursorAbsRow + 1) ?? []; - const leakedText = rowsBelow.filter(l => l.trim().length > 0 && l.includes('AAA')); - if (leakedText.length === 0) { - console.log('PASS: No text leaked below cursor row'); - } else { - console.log(`FAIL: Text leaked below cursor: ${leakedText.map(l => `"${l.trim()}"`).join(', ')}`); - } - - // TEST 4: Rapid typing (paste-like) - console.log('\n--- Test 4: Rapid input (paste simulation) ---'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(500); - // Type rapidly (no delay between chars) - await page.keyboard.type('echo rapid-test-no-delay-abcdefghij', { delay: 0 }); - await page.waitForTimeout(800); - - await page.screenshot({ path: '/tmp/input-test-rapid.png' }); - console.log('Screenshot: /tmp/input-test-rapid.png'); - - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - - const afterRapid = await getTerminalContent(page); - const rapidText = afterRapid?.lines?.join('\n') ?? ''; - if (rapidText.includes('rapid-test-no-delay-abcdefghij')) { - console.log('PASS: Rapid input command output visible'); - } else { - console.log('WARN: Rapid input output not found (check screenshots)'); - } - - // Final screenshot - await page.screenshot({ path: '/tmp/input-test-final.png' }); - console.log('\nScreenshot: /tmp/input-test-final.png (final state)'); - - // Summary - console.log('\n=== Test Complete ==='); - console.log('Check screenshots in /tmp/input-test-*.png'); - console.log('Key check: no text should appear below the active cursor row'); - - } finally { - await browser.close(); - if (sessionId && !isLive) { - console.log(`\nCleaning up session ${sessionId}...`); - await deleteSession(sessionId); - } - } -} - -main().catch(err => { - console.error('Test failed:', err); - process.exit(1); -}); diff --git a/test/local-echo-basic-test.mjs b/test/local-echo-basic-test.mjs deleted file mode 100644 index ca486172..00000000 --- a/test/local-echo-basic-test.mjs +++ /dev/null @@ -1,590 +0,0 @@ -/** - * Local Echo Overlay — Basic operation tests. - * - * Tests the most fundamental user scenarios: - * 1. Type → switch tab → come back → backspace works - * 2. Type → switch tab → come back → space doesn't erase text - * 3. Type → switch tab → come back → backspace + new text works - * 4. Overlay position stays consistent across renders - * 5. Overlay doesn't shift left during Ink redraws - * 6. Type → switch → back → rapid backspace+retype - * 7. Overlay covers flushed canvas text properly - * - * Usage: node test/local-echo-basic-test.mjs - */ -import { chromium } from 'playwright'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; -const SCREENSHOT_DIR = '/tmp/local-echo-basic'; - -async function createSession(name) { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'claude', name }) - }); - if (!resp.ok) throw new Error(`Failed to create '${name}': ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForReady(id, ms = 15000) { - const start = Date.now(); - while (Date.now() - start < ms) { - const r = await fetch(`${BASE}/api/sessions/${id}`); - if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; } - await new Promise(r => setTimeout(r, 300)); - } - return false; -} - -async function getState(page) { - return page.evaluate(() => { - const app = window.app; - if (!app) return null; - const ov = app._localEchoOverlay; - const buf = app.terminal?.buffer?.active; - return { - visible: ov?.overlay?.style.display !== 'none', - text: ov?.pendingText || '', - flushed: ov?._flushedOffset || 0, - flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, - enabled: app._localEchoEnabled, - activeId: app.activeSessionId, - // Overlay position and content - overlayLeft: ov?.overlay?.style.left, - overlayTop: ov?.overlay?.style.top, - children: (() => { - if (!ov?.overlay) return []; - return Array.from(ov.overlay.children).map(c => ({ - text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''), - left: c.style.left, - top: c.style.top, - width: c.style.width, - bgColor: c.style.backgroundColor, - spanCount: c.querySelectorAll('span').length, - })); - })(), - // Prompt line from buffer - promptLine: (() => { - if (!buf) return null; - for (let row = app.terminal.rows - 1; row >= 0; row--) { - const line = buf.getLine(buf.viewportY + row); - if (!line) continue; - const text = line.translateToString(true); - if (text.includes('\u276f')) return { text, row, col: text.lastIndexOf('\u276f') }; - } - return null; - })(), - }; - }); -} - -async function focus(page) { - await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus()); - await page.waitForTimeout(200); -} - -async function switchTab(page, id) { - await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id); - await page.waitForTimeout(1500); -} - -async function waitForPrompt(page, timeoutMs = 10000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const state = await getState(page); - if (state?.promptLine) return true; - await page.waitForTimeout(200); - } - return false; -} - -async function sc(page, name) { - const path = `${SCREENSHOT_DIR}/${name}.png`; - await page.screenshot({ path }); - console.log(` 📸 ${path}`); -} - -const R = []; -const pass = (n) => { R.push({n, s:'PASS'}); console.log(` ✅ ${n}`); }; -const fail = (n, d) => { R.push({n, s:'FAIL', d}); console.log(` ❌ ${n} — ${d}`); }; - -async function main() { - const { mkdirSync } = await import('fs'); - try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} - - console.log('=== Local Echo — Basic Operation Tests ===\n'); - - const sA = await createSession('basic-A'); - const sB = await createSession('basic-B'); - console.log(` A: ${sA}\n B: ${sB}`); - if (!await waitForReady(sA) || !await waitForReady(sB)) { - await deleteSession(sA); await deleteSession(sB); - throw new Error('Sessions not ready'); - } - console.log(' Ready\n'); - - const browser = await chromium.launch({ headless: true }); - const ctx = await browser.newContext({ - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', - isMobile: true, hasTouch: true, - }); - const page = await ctx.newPage(); - - // Capture JS errors - const jsErrors = []; - page.on('pageerror', err => jsErrors.push(err.message)); - - try { - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2000); - - // Enable local echo - await page.evaluate(() => { - const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); - s.localEchoEnabled = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(s)); - window.app?._updateLocalEchoState?.(); - }); - - // Select tab A, wait for prompt - await switchTab(page, sA); - await focus(page); - if (!await waitForPrompt(page)) throw new Error('No prompt on A'); - - // ══════════════════════════════════════════════════════════════ - // TEST 1: Type → switch → back → IMMEDIATE backspace - // ══════════════════════════════════════════════════════════════ - console.log('--- 1. Type → switch → back → immediate backspace ---'); - await page.keyboard.type('hello world', { delay: 30 }); - await page.waitForTimeout(300); - - let st = await getState(page); - console.log(` Typed: text="${st?.text}"`); - if (st?.text === 'hello world') pass('1a: Text buffered'); - else fail('1a: Text buffered', `"${st?.text}"`); - await sc(page, '01-typed'); - - // Switch to B and back - await switchTab(page, sB); - st = await getState(page); - console.log(` On B: text="${st?.text}", flushedMap=${JSON.stringify(st?.flushedMap)}`); - - await switchTab(page, sA); - await focus(page); - - // Check state BEFORE doing anything — flushed offset should be restored - st = await getState(page); - console.log(` Back on A: text="${st?.text}", flushed=${st?.flushed}`); - if (st?.flushed === 11) pass('1b: Flushed offset restored'); - else fail('1b: Flushed offset', `expected 11, got ${st?.flushed}`); - - // IMMEDIATELY backspace (don't wait for buffer load) - await page.keyboard.press('Backspace'); - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` After 1 backspace: text="${st?.text}", flushed=${st?.flushed}`); - if (st?.flushed === 10) pass('1c: Backspace decremented flushed'); - else fail('1c: Backspace', `expected flushed=10, got ${st?.flushed}`); - await sc(page, '02-after-backspace'); - - // Backspace 5 more times - for (let i = 0; i < 5; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - } - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` After 6 total backspaces: flushed=${st?.flushed}`); - if (st?.flushed === 5) pass('1d: Multiple backspaces work'); - else fail('1d: Multiple backspaces', `expected flushed=5, got ${st?.flushed}`); - - // Clean up - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 2: Type → switch → back → IMMEDIATE space (don't erase) - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 2. Type → switch → back → immediate space ---'); - await focus(page); - await waitForPrompt(page); - await page.keyboard.type('test input', { delay: 30 }); - await page.waitForTimeout(300); - - // Switch and return - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - - // Check flushed offset is restored - st = await getState(page); - console.log(` Back on A: flushed=${st?.flushed}`); - - // Type a space immediately - await page.keyboard.type(' ', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` After space: text="${st?.text}", flushed=${st?.flushed}`); - console.log(` Children: ${JSON.stringify(st?.children?.map(c => c.text))}`); - await sc(page, '03-after-space'); - - // The overlay should show the flushed text + space - if (st?.text === ' ' && st?.flushed > 0) pass('2a: Space typed with flushed offset'); - else fail('2a: Space', `text="${st?.text}", flushed=${st?.flushed}`); - - // The first child should contain the flushed text + space (not just space) - const child = st?.children?.[0]; - if (child && child.text && child.text.length > 1) { - pass('2b: Overlay shows flushed text + space (not just space)'); - console.log(` Overlay text: "${child.text}"`); - } else { - fail('2b: Overlay content', `child text="${child?.text}"`); - } - - // Clean up - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 3: Type → switch → back → backspace some + type new text - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 3. Type → switch → back → backspace + retype ---'); - await focus(page); - await waitForPrompt(page); - await page.keyboard.type('old text here', { delay: 30 }); - await page.waitForTimeout(300); - - // Switch and return - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await page.waitForTimeout(500); // Small wait for buffer - - // Backspace 4 chars ("here") - for (let i = 0; i < 4; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - } - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` After 4 backspaces: flushed=${st?.flushed}, text="${st?.text}"`); - if (st?.flushed === 9) pass('3a: Backspace 4 leaves flushed=9'); - else fail('3a: Backspace count', `expected flushed=9, got ${st?.flushed}`); - - // Now type new text - await page.keyboard.type('new stuff', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` After retype: text="${st?.text}", flushed=${st?.flushed}`); - console.log(` Overlay: ${JSON.stringify(st?.children?.map(c => c.text))}`); - await sc(page, '04-backspace-retype'); - - if (st?.text === 'new stuff') pass('3b: New text after backspace correct'); - else fail('3b: New text', `"${st?.text}"`); - - // Overlay should show flushed chars + new text - const overlayText3 = st?.children?.[0]?.text || ''; - console.log(` Full overlay text: "${overlayText3}"`); - if (overlayText3.endsWith('new stuff') && overlayText3.length > 'new stuff'.length) { - pass('3c: Overlay combines flushed + new text'); - } else { - fail('3c: Combined overlay', `"${overlayText3}"`); - } - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 4: Overlay position consistency - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 4. Overlay position consistency ---'); - await focus(page); - await waitForPrompt(page); - - // Type and record position - await page.keyboard.type('position test', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - const pos1 = { - overlayLeft: st?.overlayLeft, - overlayTop: st?.overlayTop, - childLeft: st?.children?.[0]?.left, - promptCol: st?.promptLine?.col, - }; - console.log(` Initial position: overlay=(${pos1.overlayLeft}, ${pos1.overlayTop}), child left=${pos1.childLeft}, prompt col=${pos1.promptCol}`); - await sc(page, '05-position-1'); - - // Type more text — position should stay stable (left doesn't change) - await page.keyboard.type('!', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - const pos2 = { - overlayLeft: st?.overlayLeft, - overlayTop: st?.overlayTop, - childLeft: st?.children?.[0]?.left, - promptCol: st?.promptLine?.col, - }; - console.log(` After more text: overlay=(${pos2.overlayLeft}, ${pos2.overlayTop}), child left=${pos2.childLeft}, prompt col=${pos2.promptCol}`); - - if (pos1.overlayLeft === pos2.overlayLeft && pos1.overlayTop === pos2.overlayTop) { - pass('4a: Overlay container position stable'); - } else { - fail('4a: Overlay shifted', `${pos1.overlayLeft},${pos1.overlayTop} → ${pos2.overlayLeft},${pos2.overlayTop}`); - } - - if (pos1.childLeft === pos2.childLeft) { - pass('4b: Child left position stable'); - } else { - fail('4b: Child left shifted', `${pos1.childLeft} → ${pos2.childLeft}`); - } - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 5: Switch → return → type → position matches prompt - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 5. Position after tab switch ---'); - await focus(page); - await waitForPrompt(page); - - // Record prompt position - st = await getState(page); - const promptCol5 = st?.promptLine?.col; - console.log(` Prompt col before: ${promptCol5}`); - - await page.keyboard.type('before switch', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - const beforeLeft = st?.children?.[0]?.left; - console.log(` Overlay left before switch: ${beforeLeft}`); - await sc(page, '06-before-switch'); - - // Switch and return - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await page.waitForTimeout(500); - - // Type something - await page.keyboard.type(' more', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - const afterLeft = st?.children?.[0]?.left; - const promptCol5After = st?.promptLine?.col; - console.log(` Prompt col after: ${promptCol5After}`); - console.log(` Overlay left after switch+type: ${afterLeft}`); - await sc(page, '07-after-switch-type'); - - if (beforeLeft === afterLeft) { - pass('5a: Overlay position stable after tab switch'); - } else { - fail('5a: Position shifted after switch', `${beforeLeft} → ${afterLeft}`); - } - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 6: Rapid backspace-and-retype after return - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 6. Rapid backspace-and-retype ---'); - await focus(page); - await waitForPrompt(page); - await page.keyboard.type('rapid test', { delay: 20 }); - await page.waitForTimeout(200); - - // Switch and return - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - - // Rapidly: backspace 4, type "done" - for (let i = 0; i < 4; i++) await page.keyboard.press('Backspace'); - await page.keyboard.type('done', { delay: 10 }); - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` After rapid edit: text="${st?.text}", flushed=${st?.flushed}`); - await sc(page, '08-rapid-edit'); - - if (st?.text === 'done') pass('6a: Rapid backspace+retype text correct'); - else fail('6a: Rapid edit text', `"${st?.text}"`); - - if (st?.flushed === 6) pass('6b: Flushed offset correct after rapid backspace'); - else fail('6b: Flushed offset', `expected 6, got ${st?.flushed}`); - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 7: Overlay background covers canvas text - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 7. Overlay covers canvas text ---'); - await focus(page); - await waitForPrompt(page); - await page.keyboard.type('canvas cover', { delay: 30 }); - await page.waitForTimeout(200); - - // Flush by switching tabs - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await page.waitForTimeout(500); // Wait for buffer - - // Type a space — overlay should render flushed text + space - await page.keyboard.type('X', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - const overlayChild = st?.children?.[0]; - console.log(` Overlay: visible=${st?.visible}, text="${overlayChild?.text}", width=${overlayChild?.width}, bg=${overlayChild?.bgColor}`); - await sc(page, '09-canvas-cover'); - - if (st?.visible && overlayChild?.text?.includes('canvas cover')) { - pass('7a: Overlay contains flushed text'); - } else { - fail('7a: Flushed text in overlay', `"${overlayChild?.text}"`); - } - - if (overlayChild?.bgColor) { - pass('7b: Overlay has background color'); - } else { - fail('7b: No background color', `bg="${overlayChild?.bgColor}"`); - } - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 8: Backspace ALL flushed text back to zero - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 8. Full backspace through flushed text ---'); - await focus(page); - await waitForPrompt(page); - await page.keyboard.type('abcde', { delay: 20 }); - await page.waitForTimeout(200); - - // Flush - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - - st = await getState(page); - console.log(` Flushed: ${st?.flushed}`); - - // Backspace ALL 5 chars + 2 extra (should be no-ops) - for (let i = 0; i < 7; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(40); - } - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` After full backspace: flushed=${st?.flushed}, text="${st?.text}", visible=${st?.visible}`); - await sc(page, '10-full-backspace'); - - if (st?.flushed === 0) pass('8a: Flushed offset back to 0'); - else fail('8a: Flushed not zero', `${st?.flushed}`); - - if (!st?.text && !st?.visible) pass('8b: Overlay hidden after full backspace'); - else fail('8b: Overlay state', `text="${st?.text}", visible=${st?.visible}`); - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // TEST 9: Fresh type after full backspace - // ══════════════════════════════════════════════════════════════ - console.log('\n--- 9. Type fresh after full backspace ---'); - await focus(page); - await waitForPrompt(page); - await page.keyboard.type('delete me', { delay: 20 }); - await page.waitForTimeout(200); - - // Flush - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - - // Delete all - for (let i = 0; i < 12; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(30); - } - await page.waitForTimeout(200); - - // Now type fresh text - await page.keyboard.type('brand new', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - console.log(` Fresh type: text="${st?.text}", flushed=${st?.flushed}, visible=${st?.visible}`); - await sc(page, '11-fresh-type'); - - if (st?.text === 'brand new' && st?.flushed === 0) { - pass('9a: Clean fresh text after full delete'); - } else { - fail('9a: Fresh text', `text="${st?.text}", flushed=${st?.flushed}`); - } - - if (st?.visible) pass('9b: Overlay visible with fresh text'); - else fail('9b: Overlay not visible'); - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════════════════════════ - // JS ERRORS - // ══════════════════════════════════════════════════════════════ - console.log('\n--- JS Errors ---'); - if (jsErrors.length === 0) { - pass('No JS errors'); - } else { - for (const err of jsErrors) { - fail('JS Error', err.slice(0, 120)); - } - } - - await sc(page, '12-final'); - - // ══════════════════════════════════════════════════════════════ - // SUMMARY - // ══════════════════════════════════════════════════════════════ - console.log('\n=== SUMMARY ==='); - const passes = R.filter(r => r.s === 'PASS').length; - const fails = R.filter(r => r.s === 'FAIL').length; - console.log(` ${passes} passed, ${fails} failed`); - - if (fails > 0) { - console.log('\n FAILURES:'); - R.filter(r => r.s === 'FAIL').forEach(r => console.log(` ❌ ${r.n}: ${r.d}`)); - } - - } finally { - await browser.close(); - console.log('\nCleaning up...'); - await deleteSession(sA); - await deleteSession(sB); - } -} - -main().catch(err => { console.error('Test failed:', err); process.exit(1); }); diff --git a/test/local-echo-daily-test.mjs b/test/local-echo-daily-test.mjs deleted file mode 100644 index ade03727..00000000 --- a/test/local-echo-daily-test.mjs +++ /dev/null @@ -1,395 +0,0 @@ -/** - * Local Echo Overlay — Daily use scenarios test. - * - * Simulates real mobile usage: - * 1. Type text → switch tabs → switch back → verify state - * 2. Type → change mind → delete all → type something else - * 3. Type → reload page → verify recovery - * 4. Rapidly type-delete-type (simulate editing) - * 5. Type on Tab A → switch B → type on B → switch A → verify both - * 6. Type → Enter → wait for response → type again - * 7. Partial delete then add more text - * - * Usage: node test/local-echo-daily-test.mjs - */ -import { chromium } from 'playwright'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; -const SCREENSHOT_DIR = '/tmp/local-echo-daily'; - -async function createSession(name) { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'claude', name }) - }); - if (!resp.ok) throw new Error(`Failed: ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForReady(id, ms = 15000) { - const start = Date.now(); - while (Date.now() - start < ms) { - const r = await fetch(`${BASE}/api/sessions/${id}`); - if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; } - await new Promise(r => setTimeout(r, 300)); - } - return false; -} - -async function getState(page) { - return page.evaluate(() => { - const app = window.app; - if (!app) return null; - const ov = app._localEchoOverlay; - return { - visible: ov?.overlay?.style.display !== 'none', - text: ov?.pendingText || '', - flushed: ov?._flushedOffset || 0, - flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, - enabled: app._localEchoEnabled, - activeId: app.activeSessionId, - children: (() => { - if (!ov?.overlay) return []; - return Array.from(ov.overlay.children).map(c => ({ - text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''), - left: c.style.left, top: c.style.top, - })); - })(), - }; - }); -} - -async function focus(page) { - await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus()); - await page.waitForTimeout(200); -} - -async function switchTab(page, id) { - await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id); - await page.waitForTimeout(1500); -} - -async function sc(page, name) { - const p = `${SCREENSHOT_DIR}/${name}.png`; - await page.screenshot({ path: p }); - console.log(` 📸 ${p}`); -} - -const R = []; -const pass = (n) => { R.push({n, s:'PASS'}); console.log(` ✅ ${n}`); }; -const fail = (n, d) => { R.push({n, s:'FAIL', d}); console.log(` ❌ ${n} — ${d}`); }; -const info = (n, d) => { R.push({n, s:'INFO', d}); console.log(` ℹ️ ${n} — ${d}`); }; - -async function main() { - const { mkdirSync } = await import('fs'); - try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} - - console.log('=== Local Echo — Daily Use Tests ===\n'); - - const sA = await createSession('daily-A'); - const sB = await createSession('daily-B'); - console.log(` A: ${sA}\n B: ${sB}`); - if (!await waitForReady(sA) || !await waitForReady(sB)) { - await deleteSession(sA); await deleteSession(sB); - throw new Error('Sessions not ready'); - } - console.log(' Ready\n'); - - const browser = await chromium.launch({ headless: true }); - const ctx = await browser.newContext({ - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', - isMobile: true, hasTouch: true, - }); - const page = await ctx.newPage(); - - try { - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2000); - - // Enable local echo - await page.evaluate(() => { - const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); - s.localEchoEnabled = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(s)); - window.app?._updateLocalEchoState?.(); - }); - - await switchTab(page, sA); - await focus(page); - - // ══════════════════════════════════════════ - // 1. Type → switch tab → switch back - // ══════════════════════════════════════════ - console.log('--- 1. Type → Tab switch → Return ---'); - await page.keyboard.type('fix the login bug', { delay: 40 }); - await page.waitForTimeout(300); - let st = await getState(page); - await sc(page, '01-typed'); - console.log(` Typed: "${st?.text}"`); - - if (st?.text === 'fix the login bug') pass('Text buffered'); - else fail('Buffer', `"${st?.text}"`); - - // Switch to B - await switchTab(page, sB); - st = await getState(page); - console.log(` After switch to B: text="${st?.text}", flushed A=${st?.flushedMap?.[sA]}`); - await sc(page, '02-on-tab-b'); - - if (!st?.text) pass('Overlay cleared on switch'); - else fail('Clear on switch', `"${st?.text}"`); - - // Switch back to A - await switchTab(page, sA); - await focus(page); - st = await getState(page); - await sc(page, '03-back-on-a'); - console.log(` Back on A: text="${st?.text}", flushed=${st?.flushed}`); - - if (!st?.text && st?.flushed > 0) pass('Flushed text preserved on return'); - else info('Return state', `text="${st?.text}", flushed=${st?.flushed}`); - - // Type more - await page.keyboard.type(' please', { delay: 40 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, '04-type-more-on-a'); - console.log(` After adding: text="${st?.text}", flushed=${st?.flushed}`); - - if (st?.text === ' please' && st?.flushed > 0) pass('Can type after return with flushed offset'); - else fail('Type after return', `text="${st?.text}", flushed=${st?.flushed}`); - - // Submit - await page.keyboard.press('Enter'); - await page.waitForTimeout(2000); - - // ══════════════════════════════════════════ - // 2. Type → delete all → retype - // ══════════════════════════════════════════ - console.log('\n--- 2. Type → delete all → retype ---'); - await focus(page); - await page.keyboard.type('wrong command oops', { delay: 30 }); - await page.waitForTimeout(200); - st = await getState(page); - console.log(` Typed wrong: "${st?.text}"`); - - // Delete all - for (let i = 0; i < 'wrong command oops'.length + 2; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(20); - } - await page.waitForTimeout(200); - - st = await getState(page); - console.log(` After delete: text="${st?.text}", visible=${st?.visible}`); - if (!st?.text && !st?.visible) pass('Fully deleted'); - else fail('Full delete', `"${st?.text}"`); - - // Retype correct command - await page.keyboard.type('say hello', { delay: 40 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, '05-retyped'); - console.log(` Retyped: "${st?.text}"`); - - if (st?.text === 'say hello' && st?.visible) pass('Retype after delete works'); - else fail('Retype', `"${st?.text}", visible=${st?.visible}`); - - await page.keyboard.press('Enter'); - await page.waitForTimeout(2000); - - // ══════════════════════════════════════════ - // 3. Type → reload page → check recovery - // ══════════════════════════════════════════ - console.log('\n--- 3. Type → reload page → recovery ---'); - await focus(page); - await page.keyboard.type('unsent text before reload', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - console.log(` Before reload: "${st?.text}"`); - await sc(page, '06-before-reload'); - - // Reload the page - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(3000); // Wait for SSE reconnect + buffer load - - st = await getState(page); - await sc(page, '07-after-reload'); - console.log(` After reload: text="${st?.text}", enabled=${st?.enabled}`); - - // localStorage persistence was removed — stale text from previous page - // loads caused ghost-merging with new input, breaking editing. - // After reload, overlay should be clean (no stale text). - if (st?.text === '') { - pass('Overlay clean after reload (no stale text)'); - } else { - fail('Stale text after reload', `text="${st?.text}"`); - } - - // ══════════════════════════════════════════ - // 4. Rapid type-delete-type (simulate editing) - // ══════════════════════════════════════════ - console.log('\n--- 4. Rapid type-delete-type editing ---'); - await focus(page); - - // Type "hello", delete "lo", type "p me" - await page.keyboard.type('hello', { delay: 20 }); - await page.waitForTimeout(100); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(100); - await page.keyboard.type('p me', { delay: 20 }); - await page.waitForTimeout(300); - - st = await getState(page); - await sc(page, '08-rapid-edit'); - console.log(` After rapid edit: "${st?.text}"`); - - if (st?.text === 'help me') pass('Rapid edit produces correct text'); - else fail('Rapid edit', `expected "help me", got "${st?.text}"`); - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════ - // 5. Interleaved typing on two tabs - // ══════════════════════════════════════════ - console.log('\n--- 5. Interleaved typing on two tabs ---'); - await focus(page); - await page.keyboard.type('tab A input', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, '09-tab-a-input'); - - st = await getState(page); - console.log(` Tab A: "${st?.text}"`); - if (st?.text === 'tab A input') pass('Tab A text correct'); - else fail('Tab A text', `"${st?.text}"`); - - // Switch to B and type - await switchTab(page, sB); - await focus(page); - await page.keyboard.type('tab B input', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, '10-tab-b-input'); - - st = await getState(page); - console.log(` Tab B: "${st?.text}"`); - if (st?.text === 'tab B input') pass('Tab B text correct'); - else fail('Tab B text', `"${st?.text}"`); - - // Switch back to A — A's text was flushed - await switchTab(page, sA); - await focus(page); - st = await getState(page); - await sc(page, '11-back-to-a'); - console.log(` Back to A: text="${st?.text}", flushed=${st?.flushed}, flushedMap A=${st?.flushedMap?.[sA]}, B=${st?.flushedMap?.[sB]}`); - - // A's input was flushed (11 chars), B's was flushed (11 chars) - if (st?.flushed > 0) pass('Tab A flushed offset restored'); - else info('Tab A flushed', `offset=${st?.flushed}`); - - // Switch to B — B's input was flushed - await switchTab(page, sB); - await focus(page); - st = await getState(page); - await sc(page, '12-back-to-b'); - console.log(` Back to B: text="${st?.text}", flushed=${st?.flushed}`); - - if (st?.flushed > 0) pass('Tab B flushed offset restored'); - else info('Tab B flushed', `offset=${st?.flushed}`); - - // Submit both with Enter - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - await switchTab(page, sA); - await focus(page); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - - // ══════════════════════════════════════════ - // 6. Type → Enter → response → type again - // ══════════════════════════════════════════ - console.log('\n--- 6. Type → Enter → wait → type again ---'); - await focus(page); - await page.keyboard.type('just say ok', { delay: 30 }); - await page.keyboard.press('Enter'); - await page.waitForTimeout(4000); // Wait for response - - await focus(page); - await page.keyboard.type('now say goodbye', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - await sc(page, '13-second-prompt'); - console.log(` Second prompt: "${st?.text}", flushed=${st?.flushed}`); - - if (st?.text === 'now say goodbye' && st?.flushed === 0) { - pass('Clean typing on new prompt after response'); - } else { - fail('Second prompt', `text="${st?.text}", flushed=${st?.flushed}`); - } - - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════ - // 7. Partial delete then add text - // ══════════════════════════════════════════ - console.log('\n--- 7. Partial delete + add ---'); - await focus(page); - await page.keyboard.type('fixing the bug', { delay: 30 }); - await page.waitForTimeout(200); - - // Delete "bug" (3 chars), keeping the space - for (let i = 0; i < 3; i++) await page.keyboard.press('Backspace'); - await page.waitForTimeout(100); - - // Add "typo" - await page.keyboard.type('typo', { delay: 30 }); - await page.waitForTimeout(300); - - st = await getState(page); - await sc(page, '14-partial-edit'); - console.log(` After partial edit: "${st?.text}"`); - - if (st?.text === 'fixing the typo') pass('Partial delete + add correct'); - else fail('Partial edit', `expected "fixing the typo", got "${st?.text}"`); - - // Final - await page.keyboard.press('Control+c'); - await page.waitForTimeout(300); - await sc(page, '15-final'); - - // ══════════════════════════════════════════ - // SUMMARY - // ══════════════════════════════════════════ - console.log('\n=== SUMMARY ==='); - const p = R.filter(r => r.s === 'PASS').length; - const f = R.filter(r => r.s === 'FAIL').length; - const i = R.filter(r => r.s === 'INFO').length; - console.log(` ${p} passed, ${f} failed, ${i} info`); - - if (f > 0) { - console.log('\n FAILURES:'); - R.filter(r => r.s === 'FAIL').forEach(r => console.log(` ❌ ${r.n}: ${r.d}`)); - } - - } finally { - await browser.close(); - console.log('\nCleaning up...'); - await deleteSession(sA); - await deleteSession(sB); - } -} - -main().catch(err => { console.error('Test failed:', err); process.exit(1); }); diff --git a/test/local-echo-edge-test.mjs b/test/local-echo-edge-test.mjs deleted file mode 100644 index 005c3841..00000000 --- a/test/local-echo-edge-test.mjs +++ /dev/null @@ -1,533 +0,0 @@ -/** - * Local Echo Overlay — Comprehensive edge case tests. - * - * Tests: reload, tab switching, backspace, Ctrl+C, paste, rapid switches, - * empty Enter, long text wrap, flushed offset tracking, and font rendering. - * - * Usage: node test/local-echo-edge-test.mjs - */ -import { chromium } from 'playwright'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; -const SCREENSHOT_DIR = '/tmp/local-echo-edge'; - -const results = []; -function pass(name) { results.push({ name, ok: true }); console.log(` ✅ ${name}`); } -function fail(name, detail) { results.push({ name, ok: false, detail }); console.log(` ❌ ${name} — ${detail}`); } - -async function createSession(name) { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'claude', name }) - }); - if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForReady(id, ms = 15000) { - const start = Date.now(); - while (Date.now() - start < ms) { - const r = await fetch(`${BASE}/api/sessions/${id}`); - if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; } - await new Promise(r => setTimeout(r, 300)); - } - return false; -} - -async function getState(page) { - return page.evaluate(() => { - const app = window.app; - if (!app) return null; - const overlay = app._localEchoOverlay; - return { - localEchoEnabled: app._localEchoEnabled, - overlayVisible: overlay?.overlay?.style.display !== 'none', - overlayText: overlay?.pendingText || '', - flushedOffset: overlay?._flushedOffset || 0, - flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, - activeSessionId: app.activeSessionId, - overlayChildCount: overlay?.overlay?.children?.length || 0, - overlaySpanCSS: (() => { - const span = overlay?.overlay?.children?.[0]?.children?.[0]; - if (!span) return null; - const cs = getComputedStyle(span); - return { - fontSmoothing: cs.webkitFontSmoothing || 'N/A', - textRendering: cs.textRendering, - fontFeatures: cs.fontFeatureSettings, - }; - })(), - }; - }); -} - -async function focusTerminal(page) { - await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus()); - await page.waitForTimeout(200); -} - -async function screenshot(page, name) { - const path = `${SCREENSHOT_DIR}/${name}.png`; - await page.screenshot({ path, fullPage: false }); - console.log(` 📸 ${name}.png`); -} - -async function selectSession(page, sessionId) { - await page.evaluate((id) => { - const t = document.querySelector(`.session-tab[data-id="${id}"]`); - if (t) t.click(); - }, sessionId); - await page.waitForTimeout(1500); -} - -async function main() { - const { mkdirSync } = await import('fs'); - try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} - - console.log('=== Local Echo — Comprehensive Edge Case Tests ===\n'); - - const sA = await createSession('edge-A'); - const sB = await createSession('edge-B'); - const sC = await createSession('edge-C'); - console.log(` Sessions: A=${sA.slice(0,8)}, B=${sB.slice(0,8)}, C=${sC.slice(0,8)}`); - - for (const s of [sA, sB, sC]) { - if (!await waitForReady(s)) { console.error(`Session ${s} not ready`); process.exit(1); } - } - console.log(' All ready.\n'); - - const browser = await chromium.launch({ headless: true, args: ['--window-size=390,844'] }); - const ctx = await browser.newContext({ - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', - isMobile: true, hasTouch: true, - }); - const page = await ctx.newPage(); - const jsErrors = []; - page.on('pageerror', err => jsErrors.push(err.message)); - - try { - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2000); - - // Enable local echo - await page.evaluate(() => { - const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); - s.localEchoEnabled = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(s)); - window.app?._updateLocalEchoState?.(); - }); - - // ═══════════════════════════════════════════ - // 1. Fresh typing on session A - // ═══════════════════════════════════════════ - console.log('── 1. Fresh typing ──'); - await selectSession(page, sA); - // Wait for ❯ prompt to appear in terminal buffer (Claude Code startup) - for (let i = 0; i < 20; i++) { - const hasPrompt = await page.evaluate(() => { - const buf = window.app?.terminal?.buffer?.active; - if (!buf) return false; - for (let r = 0; r < window.app.terminal.rows; r++) { - const line = buf.getLine(buf.viewportY + r); - if (line && line.translateToString(true).includes('\u276f')) return true; - } - return false; - }); - if (hasPrompt) break; - await page.waitForTimeout(500); - } - await focusTerminal(page); - await page.keyboard.type('hello world', { delay: 20 }); - await page.waitForTimeout(200); - let st = await getState(page); - if (st.overlayText === 'hello world' && st.overlayVisible) pass('Fresh typing shows in overlay'); - else fail('Fresh typing', `text="${st.overlayText}", visible=${st.overlayVisible}`); - if (st.overlaySpanCSS?.textRendering === 'geometricprecision') pass('Font CSS applied'); - else fail('Font CSS', JSON.stringify(st.overlaySpanCSS)); - await screenshot(page, '01-fresh-type'); - - // ═══════════════════════════════════════════ - // 2. Backspace removes from overlay (not PTY) - // ═══════════════════════════════════════════ - console.log('── 2. Backspace ──'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(200); - st = await getState(page); - if (st.overlayText === 'hello wor') pass('Backspace removes chars from overlay'); - else fail('Backspace', `text="${st.overlayText}"`); - - // Backspace to empty - for (let i = 0; i < 20; i++) await page.keyboard.press('Backspace'); - await page.waitForTimeout(200); - st = await getState(page); - if (st.overlayText === '' && !st.overlayVisible) pass('Backspace to empty hides overlay'); - else fail('Backspace to empty', `text="${st.overlayText}", visible=${st.overlayVisible}`); - - // Extra backspace on empty — should not crash - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(100); - st = await getState(page); - if (st.overlayText === '') pass('Extra backspace on empty is no-op'); - else fail('Extra backspace', `text="${st.overlayText}"`); - - // ═══════════════════════════════════════════ - // 3. Enter on empty prompt — no-op - // ═══════════════════════════════════════════ - console.log('── 3. Enter on empty ──'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(500); - st = await getState(page); - if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter on empty is harmless'); - else fail('Enter on empty', `text="${st.overlayText}", flushed=${st.flushedOffset}`); - - // ═══════════════════════════════════════════ - // 4. Tab switch A→B: text flushed to PTY - // ═══════════════════════════════════════════ - console.log('── 4. Tab switch flush ──'); - await focusTerminal(page); - await page.keyboard.type('flush-me', { delay: 20 }); - await page.waitForTimeout(200); - - await selectSession(page, sB); - await page.waitForTimeout(500); - st = await getState(page); - if (st.overlayText === '') pass('Overlay cleared after tab switch'); - else fail('Overlay after switch', `text="${st.overlayText}"`); - const flushedA = st.flushedOffsets[sA] || 0; - if (flushedA === 8) pass('Flushed offset tracked for session A (8 chars)'); - else fail('Flushed offset', `expected 8, got ${flushedA}`); - - // ═══════════════════════════════════════════ - // 5. Type on B, switch to C, switch back to B - // ═══════════════════════════════════════════ - console.log('── 5. Triple switch B→C→B ──'); - await focusTerminal(page); - await page.keyboard.type('on-B', { delay: 20 }); - await page.waitForTimeout(200); - - await selectSession(page, sC); - await page.waitForTimeout(500); - st = await getState(page); - const flushedB = st.flushedOffsets[sB] || 0; - if (flushedB === 4) pass('B flushed on switch to C'); - else fail('B flush', `expected 4, got ${flushedB}`); - - await focusTerminal(page); - await page.keyboard.type('on-C', { delay: 20 }); - await page.waitForTimeout(200); - - await selectSession(page, sB); - await page.waitForTimeout(500); - st = await getState(page); - if (st.overlayText === '') pass('Overlay empty on return to B'); - else fail('Return to B overlay', `text="${st.overlayText}"`); - if (st.flushedOffset === 4) pass('Flushed offset restored for B'); - else fail('B flushed offset restore', `expected 4, got ${st.flushedOffset}`); - await screenshot(page, '05-return-to-B'); - - // ═══════════════════════════════════════════ - // 6. Type after return (append to flushed) - // ═══════════════════════════════════════════ - console.log('── 6. Type after return to flushed session ──'); - await focusTerminal(page); - await page.keyboard.type(' more', { delay: 20 }); - await page.waitForTimeout(200); - st = await getState(page); - if (st.overlayText === ' more' && st.flushedOffset === 4) pass('Append after flushed'); - else fail('Append after flush', `text="${st.overlayText}", flushed=${st.flushedOffset}`); - await screenshot(page, '06-append-after-flush'); - - // ═══════════════════════════════════════════ - // 7. Enter clears flushed offset - // ═══════════════════════════════════════════ - console.log('── 7. Enter clears flushed offset ──'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - st = await getState(page); - if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter clears overlay + flushed'); - else fail('Enter clear', `text="${st.overlayText}"`); - const bInMap = st.flushedOffsets[sB]; - if (!bInMap) pass('Flushed offset map cleared for B'); - else fail('Map clear', `B still in map: ${bInMap}`); - - // ═══════════════════════════════════════════ - // 8. Ctrl+C during typing - // ═══════════════════════════════════════════ - console.log('── 8. Ctrl+C during typing ──'); - await page.waitForTimeout(3000); - await focusTerminal(page); - await page.keyboard.type('will-be-cancelled', { delay: 20 }); - await page.waitForTimeout(200); - - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(500); - st = await getState(page); - if (st.overlayText === '') pass('Ctrl+C clears overlay'); - else fail('Ctrl+C clear', `text="${st.overlayText}"`); - await screenshot(page, '08-ctrl-c'); - - // ═══════════════════════════════════════════ - // 9. Paste (multi-char data) - // ═══════════════════════════════════════════ - console.log('── 9. Paste ──'); - await page.waitForTimeout(2000); - await focusTerminal(page); - await page.evaluate(() => { - window.app?._localEchoOverlay?.appendText('pasted-text'); - }); - await page.waitForTimeout(200); - st = await getState(page); - if (st.overlayText === 'pasted-text') pass('Paste appends to overlay'); - else fail('Paste', `text="${st.overlayText}"`); - - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(500); - - // ═══════════════════════════════════════════ - // 10. Reload — stale text NOT restored - // ═══════════════════════════════════════════ - console.log('── 10. Browser reload ──'); - await page.waitForTimeout(2000); - await focusTerminal(page); - await page.keyboard.type('will-be-lost', { delay: 20 }); - await page.waitForTimeout(200); - - const lsBefore = await page.evaluate(() => localStorage.getItem('codeman_local_echo_pending')); - if (!lsBefore) pass('localStorage not written (persist is no-op)'); - else fail('localStorage written', `value="${lsBefore}"`); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(3000); - - await page.evaluate(() => window.app?._updateLocalEchoState?.()); - - st = await getState(page); - if (st.overlayText === '') pass('Overlay empty after reload (no stale text)'); - else fail('Stale text after reload', `text="${st.overlayText}"`); - if (st.flushedOffset === 0) pass('Flushed offset reset after reload'); - else fail('Flushed offset after reload', `flushed=${st.flushedOffset}`); - if (Object.keys(st.flushedOffsets).length === 0) pass('Flushed offsets map empty after reload'); - else fail('Flushed map after reload', JSON.stringify(st.flushedOffsets)); - - // ═══════════════════════════════════════════ - // 11. Type after reload — works normally - // ═══════════════════════════════════════════ - console.log('── 11. Type after reload ──'); - await focusTerminal(page); - await page.keyboard.type('after reload', { delay: 20 }); - await page.waitForTimeout(300); - st = await getState(page); - if (st.overlayText === 'after reload' && st.overlayVisible) pass('Typing works after reload'); - else fail('Type after reload', `text="${st.overlayText}", visible=${st.overlayVisible}`); - await screenshot(page, '11-after-reload'); - - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(200); - st = await getState(page); - if (st.overlayText === 'after relo') pass('Backspace works after reload'); - else fail('Backspace after reload', `text="${st.overlayText}"`); - - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - st = await getState(page); - if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter works after reload'); - else fail('Enter after reload', `text="${st.overlayText}"`); - await screenshot(page, '11-enter-after-reload'); - - // ═══════════════════════════════════════════ - // 12. Rapid tab switching (no typing between) - // ═══════════════════════════════════════════ - console.log('── 12. Rapid tab switching ──'); - for (let i = 0; i < 3; i++) { - await selectSession(page, sA); - await page.waitForTimeout(200); - await selectSession(page, sB); - await page.waitForTimeout(200); - await selectSession(page, sC); - await page.waitForTimeout(200); - } - st = await getState(page); - if (st.overlayText === '' && st.flushedOffset === 0) pass('Rapid switches — clean state'); - else fail('Rapid switches', `text="${st.overlayText}", flushed=${st.flushedOffset}`); - - // ═══════════════════════════════════════════ - // 13. Backspace on flushed text (no pending) - // ═══════════════════════════════════════════ - console.log('── 13. Backspace on flushed text ──'); - await selectSession(page, sA); - await page.waitForTimeout(3000); - await focusTerminal(page); - await page.keyboard.type('abc', { delay: 20 }); - await page.waitForTimeout(200); - - await selectSession(page, sB); - await page.waitForTimeout(500); - await selectSession(page, sA); - await page.waitForTimeout(500); - st = await getState(page); - const flushedA2 = st.flushedOffset; - console.log(` Flushed offset for A: ${flushedA2}`); - - await focusTerminal(page); - if (flushedA2 > 0) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(300); - st = await getState(page); - if (st.flushedOffset === flushedA2 - 1) pass('Backspace decrements flushed offset'); - else fail('Backspace flushed', `expected ${flushedA2 - 1}, got ${st.flushedOffset}`); - } else { - pass('Flushed offset was 0 — skip backspace flushed test'); - } - - // ═══════════════════════════════════════════ - // 14. Overlay hidden when scrolled up - // ═══════════════════════════════════════════ - console.log('── 14. Overlay hidden on scroll up ──'); - await focusTerminal(page); - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(1000); - - await focusTerminal(page); - await page.keyboard.type('scroll-test', { delay: 20 }); - await page.waitForTimeout(200); - - await page.evaluate(() => window.app?.terminal?.scrollToTop()); - await page.waitForTimeout(300); - // Check if scroll actually happened (buffer may be too short for viewport) - const scrollState = await page.evaluate(() => { - const buf = window.app?.terminal?.buffer?.active; - return { viewportY: buf?.viewportY, baseY: buf?.baseY }; - }); - if (scrollState.viewportY === scrollState.baseY) { - // Buffer too short to scroll — skip scroll hide/show tests - pass('Scroll up hide — SKIP (buffer too short to scroll, viewportY===baseY)'); - pass('Text preserved while scrolled — SKIP'); - pass('Overlay reappears at bottom — SKIP'); - console.log(` (viewportY=${scrollState.viewportY}, baseY=${scrollState.baseY})`); - } else { - st = await getState(page); - if (!st.overlayVisible) pass('Overlay hidden when scrolled up'); - else fail('Scroll up hide', `visible=${st.overlayVisible}`); - if (st.overlayText === 'scroll-test') pass('Text preserved while scrolled'); - else fail('Text during scroll', `text="${st.overlayText}"`); - - await page.evaluate(() => window.app?.terminal?.scrollToBottom()); - await page.waitForTimeout(500); - await page.keyboard.type('x', { delay: 20 }); - await page.waitForTimeout(500); - st = await getState(page); - if (st.overlayVisible) pass('Overlay reappears at bottom'); - else fail('Scroll bottom show', `visible=${st.overlayVisible}`); - } - - // ═══════════════════════════════════════════ - // 15. Double reload - // ═══════════════════════════════════════════ - console.log('── 15. Double reload ──'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(1500); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2000); - - st = await getState(page); - if (st.overlayText === '') pass('Clean state after double reload'); - else fail('Double reload', `text="${st.overlayText}"`); - - await focusTerminal(page); - await page.keyboard.type('post-double-reload', { delay: 20 }); - await page.waitForTimeout(200); - st = await getState(page); - if (st.overlayText === 'post-double-reload') pass('Input works after double reload'); - else fail('Post double reload type', `text="${st.overlayText}"`); - await screenshot(page, '15-double-reload'); - - // ═══════════════════════════════════════════ - // 16. Ctrl+C clears flushed offset too - // ═══════════════════════════════════════════ - console.log('── 16. Ctrl+C clears flushed offsets ──'); - // Clear current text - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(2000); - - await focusTerminal(page); - await page.keyboard.type('flush-ctrl-c', { delay: 20 }); - await page.waitForTimeout(200); - // Switch to flush - await selectSession(page, sB); - await page.waitForTimeout(500); - // Switch back - await selectSession(page, sA); - await page.waitForTimeout(500); - st = await getState(page); - const preFlushed = st.flushedOffset; - - // Now Ctrl+C — should clear flushed offset - await focusTerminal(page); - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(500); - st = await getState(page); - if (st.flushedOffset === 0) pass('Ctrl+C clears flushed offset'); - else fail('Ctrl+C flushed', `expected 0, got ${st.flushedOffset} (was ${preFlushed})`); - const aInMap = st.flushedOffsets[sA]; - if (!aInMap) pass('Ctrl+C clears flushed offsets map for session'); - else fail('Ctrl+C map', `A still in map: ${aInMap}`); - - // ═══════════════════════════════════════════ - // 17. JS errors check - // ═══════════════════════════════════════════ - console.log('── 17. JS errors ──'); - if (jsErrors.length === 0) pass('No JS errors during all tests'); - else { - fail('JS errors found', `${jsErrors.length} errors`); - jsErrors.forEach(e => console.log(` ⚠️ ${e}`)); - } - - // ═══════════════════════════════════════════ - // Summary - // ═══════════════════════════════════════════ - console.log('\n═══════════════════════════════════'); - const passed = results.filter(r => r.ok).length; - const failed = results.filter(r => !r.ok).length; - console.log(` ${passed} passed, ${failed} failed out of ${results.length} checks`); - if (failed > 0) { - console.log('\n Failures:'); - results.filter(r => !r.ok).forEach(r => console.log(` ❌ ${r.name}: ${r.detail}`)); - } - console.log('═══════════════════════════════════\n'); - - } finally { - await browser.close(); - console.log(' Cleaning up sessions...'); - await deleteSession(sA); - await deleteSession(sB); - await deleteSession(sC); - console.log(' Done.\n'); - } -} - -main().catch(e => { console.error(e); process.exit(1); }); diff --git a/test/local-echo-mobile-test.mjs b/test/local-echo-mobile-test.mjs deleted file mode 100644 index 07434f8c..00000000 --- a/test/local-echo-mobile-test.mjs +++ /dev/null @@ -1,717 +0,0 @@ -/** - * Local Echo Overlay — Mobile-focused integration test. - * - * Tests: - * 1. Type text → verify overlay renders with correct font/position - * 2. Switch tabs → verify text is flushed and overlay clears - * 3. Switch back → type more → verify overlay still works - * 4. Backspace (delete chars) from old and new input - * 5. Font matching — overlay font vs terminal font comparison - * 6. Long text wrapping across lines - * - * Takes screenshots at every step for visual verification. - * - * Usage: node test/local-echo-mobile-test.mjs - */ -import { chromium } from 'playwright'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; -const SCREENSHOT_DIR = '/tmp/local-echo-mobile'; - -async function createSession(name) { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'shell', name }) - }); - if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForReady(sessionId, timeoutMs = 10000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const resp = await fetch(`${BASE}/api/sessions/${sessionId}`); - if (resp.ok) { - const data = await resp.json(); - if (data.status === 'idle' || data.status === 'busy') return true; - } - await new Promise(r => setTimeout(r, 200)); - } - return false; -} - -/** Get overlay and terminal font/position info from the page */ -async function getOverlayState(page) { - return page.evaluate(() => { - const app = window.app; - if (!app) return null; - - // Find overlay container - const overlay = app._localEchoOverlay?.overlay; - const overlayVisible = overlay && overlay.style.display !== 'none'; - const overlayText = app._localEchoOverlay?.pendingText || ''; - - // Get overlay font styles from first span child - let overlayFont = null; - if (overlay) { - const span = overlay.querySelector('span'); - if (span) { - const cs = getComputedStyle(span); - overlayFont = { - fontFamily: cs.fontFamily, - fontSize: cs.fontSize, - fontWeight: cs.fontWeight, - color: cs.color, - letterSpacing: cs.letterSpacing, - }; - } - } - - // Get terminal font styles from .xterm-rows - let terminalFont = null; - const rows = document.querySelector('.xterm-rows'); - if (rows) { - // Find an actual character span in the rendered rows - const charSpan = rows.querySelector('span[style]') || rows.querySelector('span'); - const cs = getComputedStyle(rows); - terminalFont = { - fontFamily: cs.fontFamily, - fontSize: cs.fontSize, - fontWeight: cs.fontWeight, - color: cs.color, - letterSpacing: cs.letterSpacing, - }; - // Also get char-level font if available - if (charSpan) { - const charCs = getComputedStyle(charSpan); - terminalFont.charFontFamily = charCs.fontFamily; - terminalFont.charFontSize = charCs.fontSize; - } - } - - // Get overlay DOM children info - const overlayChildren = []; - if (overlay) { - for (const child of overlay.children) { - const spans = child.querySelectorAll('span'); - const text = Array.from(spans).map(s => s.textContent).join(''); - overlayChildren.push({ - text, - left: child.style.left, - top: child.style.top, - width: child.style.width, - spanCount: spans.length, - }); - } - } - - // Get overlay position - const overlayRect = overlay?.getBoundingClientRect(); - - return { - overlayVisible, - overlayText, - overlayFont, - terminalFont, - overlayChildren, - overlayRect: overlayRect ? { - x: Math.round(overlayRect.x), - y: Math.round(overlayRect.y), - width: Math.round(overlayRect.width), - height: Math.round(overlayRect.height), - } : null, - localEchoEnabled: app._localEchoEnabled, - activeSessionId: app.activeSessionId, - flushedOffset: app._localEchoOverlay?._flushedOffset || 0, - flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, - }; - }); -} - -/** Get list of session tab IDs */ -async function getSessionTabs(page) { - return page.evaluate(() => { - const tabs = document.querySelectorAll('.session-tab'); - return Array.from(tabs).map(t => ({ - id: t.dataset.id, - name: t.querySelector('.tab-name')?.textContent || t.textContent?.trim(), - active: t.classList.contains('active'), - })); - }); -} - -/** Click a session tab by ID */ -async function clickTab(page, sessionId) { - await page.evaluate((id) => { - const tab = document.querySelector(`.session-tab[data-id="${id}"]`); - if (tab) tab.click(); - }, sessionId); - await page.waitForTimeout(1500); // Wait for buffer load -} - -async function screenshot(page, name) { - const path = `${SCREENSHOT_DIR}/${name}.png`; - await page.screenshot({ path, fullPage: false }); - console.log(` 📸 ${path}`); - return path; -} - -function compareFonts(overlayFont, terminalFont) { - if (!overlayFont || !terminalFont) return { match: false, reason: 'missing font data' }; - const issues = []; - if (overlayFont.fontFamily !== terminalFont.fontFamily) { - issues.push(`fontFamily: overlay="${overlayFont.fontFamily}" vs terminal="${terminalFont.fontFamily}"`); - } - if (overlayFont.fontSize !== terminalFont.fontSize) { - issues.push(`fontSize: overlay="${overlayFont.fontSize}" vs terminal="${terminalFont.fontSize}"`); - } - if (overlayFont.fontWeight !== terminalFont.fontWeight) { - issues.push(`fontWeight: overlay="${overlayFont.fontWeight}" vs terminal="${terminalFont.fontWeight}"`); - } - if (overlayFont.letterSpacing !== terminalFont.letterSpacing) { - issues.push(`letterSpacing: overlay="${overlayFont.letterSpacing}" vs terminal="${terminalFont.letterSpacing}"`); - } - return { match: issues.length === 0, issues }; -} - -async function main() { - // Create screenshot directory - const { mkdirSync } = await import('fs'); - try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} - - console.log('=== Local Echo Overlay — Mobile Test ===\n'); - - // Create two shell sessions for tab-switching tests - console.log('Creating test sessions...'); - const sessionA = await createSession('echo-test-A'); - const sessionB = await createSession('echo-test-B'); - console.log(` Session A: ${sessionA}`); - console.log(` Session B: ${sessionB}`); - - const readyA = await waitForReady(sessionA); - const readyB = await waitForReady(sessionB); - if (!readyA || !readyB) { - console.error('Sessions failed to become ready'); - await deleteSession(sessionA); - await deleteSession(sessionB); - process.exit(1); - } - console.log(' Both sessions ready\n'); - - // Launch browser with mobile-like viewport - const browser = await chromium.launch({ - headless: true, - args: ['--window-size=390,844'] - }); - const context = await browser.newContext({ - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', - isMobile: true, - hasTouch: true, - }); - const page = await context.newPage(); - const errors = []; - page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); }); - - const results = []; - function pass(name) { results.push({ name, status: 'PASS' }); console.log(` ✅ PASS: ${name}`); } - function fail(name, detail) { results.push({ name, status: 'FAIL', detail }); console.log(` ❌ FAIL: ${name} — ${detail}`); } - function info(name, detail) { results.push({ name, status: 'INFO', detail }); console.log(` ℹ️ INFO: ${name} — ${detail}`); } - - try { - // ── SETUP ── - console.log('Opening Codeman...'); - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2000); - - // Enable local echo via settings - console.log('Enabling local echo...'); - await page.evaluate(() => { - const settings = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); - settings.localEchoEnabled = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(settings)); - window.app?._updateLocalEchoState?.(); - }); - await page.waitForTimeout(300); - - // Switch to session A - console.log('Switching to Session A...'); - await clickTab(page, sessionA); - await page.waitForTimeout(1000); - - // Focus terminal — use the hidden textarea (xterm-viewport intercepts pointer events) - await page.evaluate(() => { - const ta = document.querySelector('.xterm-helper-textarea'); - if (ta) ta.focus(); - }); - await page.waitForTimeout(300); - - let state = await getOverlayState(page); - if (state?.localEchoEnabled) { - pass('Local echo enabled'); - } else { - fail('Local echo enabled', `localEchoEnabled=${state?.localEchoEnabled}`); - } - await screenshot(page, '01-initial-session-a'); - - // ── TEST 1: Type text on Session A ── - console.log('\n--- Test 1: Type text on Session A ---'); - const textA = 'hello-from-session-a'; - await page.keyboard.type(textA, { delay: 50 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '02-typed-text-session-a'); - - console.log(` Overlay visible: ${state?.overlayVisible}`); - console.log(` Overlay text: "${state?.overlayText}"`); - console.log(` Overlay children: ${state?.overlayChildren?.length}`); - - if (state?.overlayVisible && state?.overlayText === textA) { - pass('Overlay shows typed text'); - } else if (state?.overlayText === textA) { - info('Overlay has text but not visible', `visible=${state?.overlayVisible}`); - } else { - fail('Overlay shows typed text', `expected="${textA}" got="${state?.overlayText}"`); - } - - // ── TEST 2: Font matching ── - // Compare overlay's cached font values against xterm.js options (always available, - // unlike .xterm-rows DOM spans which don't exist with WebGL/canvas renderer). - console.log('\n--- Test 2: Font matching ---'); - const xtermFont = await page.evaluate(() => { - const t = window.app?.terminal; - if (!t) return null; - const overlay = window.app?._localEchoOverlay; - return { - xtermOpts: { - fontFamily: t.options.fontFamily, - fontSize: t.options.fontSize + 'px', - fontWeight: String(t.options.fontWeight || 'normal'), - }, - overlayCached: { - fontFamily: overlay?._fontFamily, - fontSize: overlay?._fontSize, - fontWeight: String(overlay?._fontWeight || 'normal'), - }, - }; - }); - console.log(` xterm opts: ${JSON.stringify(xtermFont?.xtermOpts)}`); - console.log(` overlay cached: ${JSON.stringify(xtermFont?.overlayCached)}`); - if (xtermFont) { - const fontIssues = []; - if (xtermFont.overlayCached.fontFamily !== xtermFont.xtermOpts.fontFamily) - fontIssues.push(`fontFamily: "${xtermFont.overlayCached.fontFamily}" vs "${xtermFont.xtermOpts.fontFamily}"`); - if (xtermFont.overlayCached.fontSize !== xtermFont.xtermOpts.fontSize) - fontIssues.push(`fontSize: "${xtermFont.overlayCached.fontSize}" vs "${xtermFont.xtermOpts.fontSize}"`); - if (xtermFont.overlayCached.fontWeight !== xtermFont.xtermOpts.fontWeight) - fontIssues.push(`fontWeight: "${xtermFont.overlayCached.fontWeight}" vs "${xtermFont.xtermOpts.fontWeight}"`); - if (fontIssues.length === 0) { - pass('Overlay font matches xterm.js options'); - } else { - fail('Font mismatch vs xterm options', fontIssues.join('; ')); - } - } else { - fail('Font comparison', 'Could not read xterm options'); - } - - // ── TEST 3: Backspace — delete some chars ── - console.log('\n--- Test 3: Backspace on Session A ---'); - // Delete last 5 chars - for (let i = 0; i < 5; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - } - await page.waitForTimeout(300); - - state = await getOverlayState(page); - await screenshot(page, '03-after-backspace-session-a'); - - const expectedAfterBackspace = textA.slice(0, -5); // "hello-from-sess" - console.log(` Expected: "${expectedAfterBackspace}"`); - console.log(` Got: "${state?.overlayText}"`); - - if (state?.overlayText === expectedAfterBackspace) { - pass('Backspace deletes from overlay correctly'); - } else { - fail('Backspace result', `expected="${expectedAfterBackspace}" got="${state?.overlayText}"`); - } - - // ── TEST 4: Switch to Session B (text should flush) ── - console.log('\n--- Test 4: Switch to Session B ---'); - const textBeforeSwitch = state?.overlayText || ''; - await clickTab(page, sessionB); - - state = await getOverlayState(page); - await screenshot(page, '04-switched-to-session-b'); - - console.log(` Active session: ${state?.activeSessionId}`); - console.log(` Overlay visible: ${state?.overlayVisible}`); - console.log(` Overlay text: "${state?.overlayText}"`); - console.log(` Flushed offsets: ${JSON.stringify(state?.flushedOffsets)}`); - - if (state?.activeSessionId === sessionB) { - pass('Switched to Session B'); - } else { - fail('Tab switch', `active=${state?.activeSessionId}, expected=${sessionB}`); - } - - if (!state?.overlayVisible && !state?.overlayText) { - pass('Overlay cleared on tab switch'); - } else { - fail('Overlay not cleared', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`); - } - - // Check if text was flushed to Session A - if (state?.flushedOffsets?.[sessionA] > 0) { - pass(`Text flushed to Session A (${state.flushedOffsets[sessionA]} chars)`); - } else { - info('Flushed offsets', JSON.stringify(state?.flushedOffsets)); - } - - // ── TEST 5: Type on Session B ── - console.log('\n--- Test 5: Type on Session B ---'); - await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); }); - await page.waitForTimeout(300); - - const textB = 'hello-from-session-b'; - await page.keyboard.type(textB, { delay: 50 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '05-typed-on-session-b'); - - console.log(` Overlay visible: ${state?.overlayVisible}`); - console.log(` Overlay text: "${state?.overlayText}"`); - - if (state?.overlayVisible && state?.overlayText === textB) { - pass('Overlay works on Session B'); - } else { - fail('Session B overlay', `expected="${textB}" got="${state?.overlayText}"`); - } - - // ── TEST 6: Switch back to Session A ── - console.log('\n--- Test 6: Switch back to Session A ---'); - await clickTab(page, sessionA); - - state = await getOverlayState(page); - await screenshot(page, '06-back-to-session-a'); - - console.log(` Active session: ${state?.activeSessionId}`); - console.log(` Overlay visible: ${state?.overlayVisible}`); - console.log(` Overlay text: "${state?.overlayText}"`); - console.log(` Flushed offset on overlay: ${state?.flushedOffset}`); - console.log(` Flushed offsets map: ${JSON.stringify(state?.flushedOffsets)}`); - - // Session A's text was flushed before we left — it should have been sent to PTY. - // The overlay should be clear (text was flushed, not pending anymore). - if (!state?.overlayText) { - pass('Session A overlay clear after return (text was flushed)'); - } else { - info('Session A has overlay text after return', `"${state?.overlayText}"`); - } - - // ── TEST 7: Type NEW text on Session A after returning ── - console.log('\n--- Test 7: Type new text after returning to Session A ---'); - await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); }); - await page.waitForTimeout(300); - - const textA2 = 'new-text-after-return'; - await page.keyboard.type(textA2, { delay: 50 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '07-new-text-session-a'); - - console.log(` Overlay visible: ${state?.overlayVisible}`); - console.log(` Overlay text: "${state?.overlayText}"`); - console.log(` Flushed offset: ${state?.flushedOffset}`); - console.log(` Overlay children: ${JSON.stringify(state?.overlayChildren)}`); - - if (state?.overlayText === textA2) { - pass('Can type new text after tab switch return'); - } else { - fail('New text after return', `expected="${textA2}" got="${state?.overlayText}"`); - } - - // ── TEST 8: Delete ALL chars with backspace ── - console.log('\n--- Test 8: Delete all chars with backspace ---'); - const deleteCount = (state?.overlayText || '').length; - for (let i = 0; i < deleteCount + 3; i++) { // +3 extra to test over-delete - await page.keyboard.press('Backspace'); - await page.waitForTimeout(30); - } - await page.waitForTimeout(300); - - state = await getOverlayState(page); - await screenshot(page, '08-all-deleted'); - - console.log(` Overlay visible: ${state?.overlayVisible}`); - console.log(` Overlay text: "${state?.overlayText}"`); - - // After deleting all typed (pending) text, overlay stays visible because - // flushed text still exists (3 extra backspaces only reduced flushed from 15→12). - // The overlay correctly shows flushed text so the user can keep backspacing. - if (state?.flushedOffset > 0 && state?.overlayVisible && !state?.overlayText) { - pass('Overlay visible with flushed text after deleting pending'); - } else if (!state?.overlayVisible && !state?.overlayText && state?.flushedOffset === 0) { - pass('Overlay hidden after deleting all chars'); - } else { - fail('Unexpected state after full delete', `visible=${state?.overlayVisible}, text="${state?.overlayText}", flushed=${state?.flushedOffset}`); - } - - // Verify backspace-into-flushed-text: the 3 extra backspaces should have - // decremented flushedOffset from 15 to 12 and sent \x7f to PTY. - const expectedFlushedAfterDelete = 15 - 3; // original 15, minus 3 extras - console.log(` Flushed offset after delete: ${state?.flushedOffset} (expected ${expectedFlushedAfterDelete})`); - if (state?.flushedOffset === expectedFlushedAfterDelete) { - pass('Backspace into flushed text decrements offset correctly'); - } else { - fail('Flushed offset after backspace', `expected ${expectedFlushedAfterDelete}, got ${state?.flushedOffset}`); - } - - // ── TEST 9: Type, submit with Enter, verify overlay clears ── - console.log('\n--- Test 9: Enter submits and clears overlay ---'); - const enterText = 'echo submit-test'; - await page.keyboard.type(enterText, { delay: 50 }); - await page.waitForTimeout(300); - - state = await getOverlayState(page); - await screenshot(page, '09-before-enter'); - - if (state?.overlayText === enterText) { - pass('Text buffered before Enter'); - } else { - info('Pre-enter text', `"${state?.overlayText}"`); - } - - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - - state = await getOverlayState(page); - await screenshot(page, '10-after-enter'); - - console.log(` Overlay visible: ${state?.overlayVisible}`); - console.log(` Overlay text: "${state?.overlayText}"`); - - if (!state?.overlayVisible && !state?.overlayText) { - pass('Overlay cleared after Enter'); - } else { - fail('Overlay not cleared after Enter', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`); - } - - // ── TEST 10: Rapid tab switching with pending text ── - console.log('\n--- Test 10: Rapid tab switching ---'); - await page.keyboard.type('rapid-switch-text', { delay: 30 }); - await page.waitForTimeout(200); - await screenshot(page, '11-before-rapid-switch'); - - // Rapid switch: A → B → A - await clickTab(page, sessionB); - await page.waitForTimeout(300); - await screenshot(page, '12-rapid-switch-to-b'); - - await clickTab(page, sessionA); - await page.waitForTimeout(300); - await screenshot(page, '13-rapid-switch-back-to-a'); - - state = await getOverlayState(page); - console.log(` After rapid switch — overlay text: "${state?.overlayText}"`); - console.log(` Flushed offsets: ${JSON.stringify(state?.flushedOffsets)}`); - - // Text should have been flushed to PTY, overlay should be clear - if (!state?.overlayText || state?.overlayText === '') { - pass('Rapid switch: overlay clear after return'); - } else { - fail('Rapid switch: overlay has stale text', `"${state?.overlayText}"`); - } - - // ── TEST 11: Font detail check — pixel-level ── - console.log('\n--- Test 11: Detailed font comparison ---'); - await page.keyboard.type('font-check', { delay: 50 }); - await page.waitForTimeout(300); - - const fontDetail = await page.evaluate(() => { - const app = window.app; - const overlay = app?._localEchoOverlay; - if (!overlay) return null; - - // Overlay span - const oSpan = overlay.overlay.querySelector('span'); - // Terminal character - const tRows = document.querySelector('.xterm-rows'); - // Find a row with actual text - let tSpan = null; - if (tRows) { - const allSpans = tRows.querySelectorAll('span'); - for (const s of allSpans) { - if (s.textContent?.trim()) { tSpan = s; break; } - } - } - - const getDetailedFont = (el) => { - if (!el) return null; - const cs = getComputedStyle(el); - return { - fontFamily: cs.fontFamily, - fontSize: cs.fontSize, - fontWeight: cs.fontWeight, - fontStyle: cs.fontStyle, - letterSpacing: cs.letterSpacing, - lineHeight: cs.lineHeight, - color: cs.color, - textRendering: cs.textRendering, - webkitFontSmoothing: cs.webkitFontSmoothing || cs['-webkit-font-smoothing'], - fontKerning: cs.fontKerning, - fontVariantLigatures: cs.fontVariantLigatures, - }; - }; - - // Also check xterm.js options - const xtermOpts = { - fontFamily: app.terminal?.options?.fontFamily, - fontSize: app.terminal?.options?.fontSize, - fontWeight: app.terminal?.options?.fontWeight, - letterSpacing: app.terminal?.options?.letterSpacing, - }; - - // Cell dimensions - let cellDims = null; - try { - const dims = app.terminal._core._renderService.dimensions; - cellDims = { - cellWidth: dims.css.cell.width, - cellHeight: dims.css.cell.height, - }; - } catch {} - - return { - overlaySpan: getDetailedFont(oSpan), - terminalSpan: getDetailedFont(tSpan), - terminalRows: getDetailedFont(tRows), - xtermOptions: xtermOpts, - cellDims, - // Check overlay cached values vs actual - overlayCached: { - fontFamily: overlay._fontFamily, - fontSize: overlay._fontSize, - fontWeight: overlay._fontWeight, - color: overlay._color, - letterSpacing: overlay._letterSpacing, - bg: overlay._bg, - }, - }; - }); - await screenshot(page, '14-font-detail-check'); - - console.log(' xterm.js options:', JSON.stringify(fontDetail?.xtermOptions, null, 4)); - console.log(' Cell dims:', JSON.stringify(fontDetail?.cellDims, null, 4)); - console.log(' Overlay cached:', JSON.stringify(fontDetail?.overlayCached, null, 4)); - console.log(' Overlay span computed:', JSON.stringify(fontDetail?.overlaySpan, null, 4)); - console.log(' Terminal span computed:', JSON.stringify(fontDetail?.terminalSpan, null, 4)); - console.log(' Terminal rows computed:', JSON.stringify(fontDetail?.terminalRows, null, 4)); - - // Check specific mismatches - if (fontDetail?.overlaySpan && fontDetail?.terminalSpan) { - const o = fontDetail.overlaySpan; - const t = fontDetail.terminalSpan; - const mismatches = []; - for (const key of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'fontKerning']) { - if (o[key] !== t[key]) { - mismatches.push(`${key}: overlay="${o[key]}" vs terminal="${t[key]}"`); - } - } - if (mismatches.length === 0) { - pass('Detailed font comparison matches'); - } else { - fail('Font mismatches found', mismatches.join('; ')); - } - } else { - info('Font detail check', 'Could not get computed styles for comparison'); - } - - // Clean up the typed text - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - for (let i = 0; i < 'font-check'.length; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(20); - } - - // ── TEST 12: Long text that wraps ── - console.log('\n--- Test 12: Long text wrapping ---'); - // Type enough to wrap past terminal width - const longText = 'A'.repeat(80) + 'B'.repeat(40); // 120 chars — likely wraps on mobile - await page.keyboard.type(longText, { delay: 5 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '15-long-text-wrap'); - - console.log(` Overlay text length: ${state?.overlayText?.length}`); - console.log(` Overlay children (lines): ${state?.overlayChildren?.length}`); - if (state?.overlayChildren) { - for (const [i, child] of state.overlayChildren.entries()) { - console.log(` Line ${i}: "${child.text}" (${child.spanCount} spans, left=${child.left}, top=${child.top})`); - } - } - - if (state?.overlayText === longText) { - pass('Long text stored correctly'); - } else { - fail('Long text storage', `expected ${longText.length} chars, got ${state?.overlayText?.length}`); - } - - if (state?.overlayChildren && state.overlayChildren.length > 1) { - pass(`Long text wraps into ${state.overlayChildren.length} lines`); - } else { - fail('Long text wrapping', `only ${state?.overlayChildren?.length} line(s)`); - } - - // Delete the long text - await page.keyboard.press('Enter'); - await page.waitForTimeout(500); - - // Final screenshot - await screenshot(page, '16-final-state'); - - // ── SUMMARY ── - console.log('\n=== SUMMARY ==='); - const passes = results.filter(r => r.status === 'PASS').length; - const fails = results.filter(r => r.status === 'FAIL').length; - const infos = results.filter(r => r.status === 'INFO').length; - console.log(` ${passes} passed, ${fails} failed, ${infos} info`); - console.log(` Screenshots saved to ${SCREENSHOT_DIR}/`); - - if (fails > 0) { - console.log('\n FAILURES:'); - for (const r of results.filter(r => r.status === 'FAIL')) { - console.log(` ❌ ${r.name}: ${r.detail}`); - } - } - - if (errors.length > 0) { - console.log(`\n Console errors (${errors.length}):`); - for (const e of errors.slice(0, 10)) console.log(` ${e}`); - } - - } finally { - await browser.close(); - console.log('\nCleaning up sessions...'); - await deleteSession(sessionA); - await deleteSession(sessionB); - } -} - -main().catch(err => { - console.error('Test failed:', err); - process.exit(1); -}); diff --git a/test/local-echo-scroll-test.mjs b/test/local-echo-scroll-test.mjs deleted file mode 100644 index 7c28133d..00000000 --- a/test/local-echo-scroll-test.mjs +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Local Echo Overlay — Scroll and position tests. - * - * Focused on: - * 1. Overlay hidden when scrolled up (prompt not at viewport bottom) - * 2. Overlay reappears when scrolled back to bottom - * 3. Overlay position correct after Ink redraws move the prompt - * 4. Overlay cursor block position matches text end - * 5. Multiple Enter submits — flushed offset doesn't carry across prompts - * 6. Backspace past all pending + all flushed = clean state - * - * Usage: node test/local-echo-scroll-test.mjs - */ -import { chromium } from 'playwright'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; -const SCREENSHOT_DIR = '/tmp/local-echo-scroll'; - -async function createSession(name) { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'claude', name }) - }); - if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForReady(sessionId, timeoutMs = 15000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const resp = await fetch(`${BASE}/api/sessions/${sessionId}`); - if (resp.ok) { - const data = await resp.json(); - if (data.status === 'idle' || data.status === 'busy') return true; - } - await new Promise(r => setTimeout(r, 300)); - } - return false; -} - -async function getState(page) { - return page.evaluate(() => { - const app = window.app; - if (!app) return null; - const overlay = app._localEchoOverlay; - const buf = app.terminal?.buffer?.active; - return { - overlayVisible: overlay?.overlay?.style.display !== 'none', - overlayText: overlay?.pendingText || '', - flushedOffset: overlay?._flushedOffset || 0, - flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, - localEchoEnabled: app._localEchoEnabled, - activeSessionId: app.activeSessionId, - // Scroll state - viewportY: buf?.viewportY, - baseY: buf?.baseY, - isAtBottom: buf ? buf.viewportY === buf.baseY : null, - terminalRows: app.terminal?.rows, - // Overlay children - overlayChildren: (() => { - if (!overlay?.overlay) return []; - return Array.from(overlay.overlay.children).map(c => ({ - text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''), - left: c.style.left, - top: c.style.top, - })); - })(), - }; - }); -} - -async function focusTerminal(page) { - await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); }); - await page.waitForTimeout(200); -} - -async function screenshot(page, name) { - const path = `${SCREENSHOT_DIR}/${name}.png`; - await page.screenshot({ path, fullPage: false }); - console.log(` 📸 ${path}`); -} - -const results = []; -function pass(name) { results.push({ name, status: 'PASS' }); console.log(` ✅ PASS: ${name}`); } -function fail(name, detail) { results.push({ name, status: 'FAIL', detail }); console.log(` ❌ FAIL: ${name} — ${detail}`); } -function info(name, detail) { results.push({ name, status: 'INFO', detail }); console.log(` ℹ️ INFO: ${name} — ${detail}`); } - -async function main() { - const { mkdirSync } = await import('fs'); - try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} - - console.log('=== Local Echo — Scroll & Position Tests ===\n'); - - const sessionA = await createSession('scroll-test-A'); - const sessionB = await createSession('scroll-test-B'); - console.log(` Session A: ${sessionA}`); - console.log(` Session B: ${sessionB}`); - - if (!await waitForReady(sessionA) || !await waitForReady(sessionB)) { - console.error('Sessions failed to become ready'); - await deleteSession(sessionA); - await deleteSession(sessionB); - process.exit(1); - } - console.log(' Ready\n'); - - const browser = await chromium.launch({ headless: true, args: ['--window-size=390,844'] }); - const context = await browser.newContext({ - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', - isMobile: true, hasTouch: true, - }); - const page = await context.newPage(); - - try { - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2000); - - // Enable local echo - await page.evaluate(() => { - const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); - s.localEchoEnabled = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(s)); - window.app?._updateLocalEchoState?.(); - }); - - // Select session A - await page.evaluate((id) => { - const t = document.querySelector(`.session-tab[data-id="${id}"]`); - if (t) t.click(); - }, sessionA); - await page.waitForTimeout(1500); - await focusTerminal(page); - - // ══════════════════════════════════════════ - // TEST 1: Overlay hidden when scrolled up - // ══════════════════════════════════════════ - console.log('--- Test 1: Overlay hidden when scrolled up ---'); - // Generate some content so we can scroll - await page.keyboard.type('just say ok', { delay: 20 }); - await page.keyboard.press('Enter'); - await page.waitForTimeout(4000); // Wait for response - - await focusTerminal(page); - await page.keyboard.type('test-scroll-text', { delay: 30 }); - await page.waitForTimeout(300); - - let state = await getState(page); - console.log(` At bottom: isAtBottom=${state?.isAtBottom}, viewportY=${state?.viewportY}, baseY=${state?.baseY}`); - await screenshot(page, '01-at-bottom-with-text'); - - if (state?.overlayVisible && state?.overlayText === 'test-scroll-text') { - pass('Overlay visible at bottom with text'); - } else { - fail('Overlay at bottom', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`); - } - - // Scroll to top - await page.evaluate(() => { window.app?.terminal?.scrollToTop(); }); - await page.waitForTimeout(300); - - state = await getState(page); - console.log(` After scroll up: isAtBottom=${state?.isAtBottom}, viewportY=${state?.viewportY}, baseY=${state?.baseY}`); - console.log(` Overlay visible: ${state?.overlayVisible}, text: "${state?.overlayText}"`); - await screenshot(page, '02-scrolled-up'); - - if (state?.isAtBottom) { - // Buffer too short to scroll — skip this check - info('Overlay scroll-up test skipped', 'buffer too short to scroll (viewportY===baseY)'); - } else if (!state?.overlayVisible) { - pass('Overlay hidden when scrolled up'); - } else { - fail('Overlay visible when scrolled up', `visible=${state?.overlayVisible}`); - } - - // Text should be preserved even though overlay is hidden - if (state?.overlayText === 'test-scroll-text') { - pass('Overlay text preserved while hidden'); - } else { - fail('Overlay text lost during scroll', `"${state?.overlayText}"`); - } - - // ══════════════════════════════════════════ - // TEST 2: Overlay reappears at bottom - // ══════════════════════════════════════════ - console.log('\n--- Test 2: Overlay reappears when scrolled back ---'); - await page.evaluate(() => { window.app?.terminal?.scrollToBottom(); }); - await page.waitForTimeout(300); - - // Type one more char to trigger re-render - await page.keyboard.type('!', { delay: 30 }); - await page.waitForTimeout(300); - - state = await getState(page); - console.log(` At bottom: isAtBottom=${state?.isAtBottom}`); - console.log(` Overlay visible: ${state?.overlayVisible}, text: "${state?.overlayText}"`); - await screenshot(page, '03-back-to-bottom'); - - if (state?.overlayVisible && state?.overlayText === 'test-scroll-text!') { - pass('Overlay reappears at bottom with preserved text'); - } else { - info('Overlay after scroll back', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`); - } - - // Clear - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════ - // TEST 3: Backspace deletes ALL flushed text back to zero - // ══════════════════════════════════════════ - console.log('\n--- Test 3: Full backspace through pending + flushed ---'); - await focusTerminal(page); - await page.keyboard.type('abcdef', { delay: 20 }); // 6 chars pending - await page.waitForTimeout(200); - - // Flush by tab switch - await page.evaluate((id) => { - document.querySelector(`.session-tab[data-id="${id}"]`)?.click(); - }, sessionB); - await page.waitForTimeout(1000); - - // Switch back - await page.evaluate((id) => { - document.querySelector(`.session-tab[data-id="${id}"]`)?.click(); - }, sessionA); - await page.waitForTimeout(1000); - await focusTerminal(page); - - // Type 3 more chars - await page.keyboard.type('xyz', { delay: 20 }); - await page.waitForTimeout(200); - - state = await getState(page); - console.log(` State: pending="${state?.overlayText}", flushed=${state?.flushedOffset}`); - - // Now backspace 3 (pending "xyz") + 6 (flushed "abcdef") + 2 extra = 11 - for (let i = 0; i < 11; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(40); - } - await page.waitForTimeout(300); - - state = await getState(page); - console.log(` After full delete: pending="${state?.overlayText}", flushed=${state?.flushedOffset}, map=${JSON.stringify(state?.flushedOffsets)}`); - await screenshot(page, '04-full-backspace'); - - if (state?.flushedOffset === 0 && !state?.overlayText) { - pass('Full backspace clears both pending and flushed'); - } else { - fail('Full backspace', `pending="${state?.overlayText}", flushed=${state?.flushedOffset}`); - } - - if (!state?.flushedOffsets?.[sessionA]) { - pass('Flushed offsets Map cleared after full backspace'); - } else { - fail('Map not cleared', JSON.stringify(state?.flushedOffsets)); - } - - // ══════════════════════════════════════════ - // TEST 4: Type → Enter → type again — no stale offset - // ══════════════════════════════════════════ - console.log('\n--- Test 4: Enter resets state cleanly ---'); - await focusTerminal(page); - await page.keyboard.type('first-prompt', { delay: 20 }); - await page.waitForTimeout(200); - await page.keyboard.press('Enter'); - await page.waitForTimeout(2000); // Wait for Claude response - - // Now type on the NEW prompt - await focusTerminal(page); - await page.keyboard.type('second-prompt', { delay: 20 }); - await page.waitForTimeout(300); - - state = await getState(page); - console.log(` After Enter+retype: pending="${state?.overlayText}", flushed=${state?.flushedOffset}`); - await screenshot(page, '05-after-enter-retype'); - - if (state?.overlayText === 'second-prompt' && state?.flushedOffset === 0) { - pass('Clean state after Enter + retype'); - } else { - fail('Stale state after Enter', `pending="${state?.overlayText}", flushed=${state?.flushedOffset}`); - } - - // Clean up - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════ - // TEST 5: Overlay cursor position matches text end - // ══════════════════════════════════════════ - console.log('\n--- Test 5: Cursor block position ---'); - await focusTerminal(page); - await page.keyboard.type('cursor-pos', { delay: 30 }); // 10 chars - await page.waitForTimeout(300); - - state = await getState(page); - const children = state?.overlayChildren || []; - console.log(` Children: ${JSON.stringify(children)}`); - - // Should have 2 children: line div + cursor span - // Cursor should be at position (startCol + 10) * cellW - if (children.length >= 2) { - const cursorChild = children[children.length - 1]; - // Cursor text should be empty (it's a block cursor, not a character) - if (cursorChild.text === '') { - pass('Cursor block present at end of text'); - } else { - fail('Cursor block', `expected empty text, got "${cursorChild.text}"`); - } - - // Check cursor is right after the text - const textChild = children[0]; - const textLeft = parseInt(textChild.left); - const cellW = 6; // from previous tests - const expectedCursorLeft = textLeft + 10 * cellW; // 10 chars of "cursor-pos" - const actualCursorLeft = parseInt(cursorChild.left); - console.log(` Text left: ${textLeft}px, cursor left: ${actualCursorLeft}px, expected: ${expectedCursorLeft}px`); - if (actualCursorLeft === expectedCursorLeft) { - pass('Cursor positioned correctly after text'); - } else { - fail('Cursor position', `expected ${expectedCursorLeft}px, got ${actualCursorLeft}px`); - } - } else { - fail('Overlay children count', `expected >=2, got ${children.length}`); - } - await screenshot(page, '06-cursor-position'); - - // Clean up - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // ══════════════════════════════════════════ - // TEST 6: Type while Claude is busy (working status) - // ══════════════════════════════════════════ - console.log('\n--- Test 6: Type while Claude is busy ---'); - await focusTerminal(page); - await page.keyboard.type('generate a long response with lots of text', { delay: 10 }); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); // Claude starts responding - - // Type while Claude is outputting - await page.keyboard.type('typing-while-busy', { delay: 30 }); - await page.waitForTimeout(300); - - state = await getState(page); - console.log(` While busy: pending="${state?.overlayText}", visible=${state?.overlayVisible}`); - await screenshot(page, '07-typing-while-busy'); - - if (state?.overlayText === 'typing-while-busy') { - pass('Can type while Claude is busy'); - } else { - info('Typing while busy', `"${state?.overlayText}"`); - } - - // Clean up - await page.keyboard.press('Control+c'); - await page.waitForTimeout(500); - - // Final screenshot - await screenshot(page, '08-final'); - - // ══════════════════════════════════════════ - // SUMMARY - // ══════════════════════════════════════════ - console.log('\n=== SUMMARY ==='); - const passes = results.filter(r => r.status === 'PASS').length; - const fails = results.filter(r => r.status === 'FAIL').length; - const infos = results.filter(r => r.status === 'INFO').length; - console.log(` ${passes} passed, ${fails} failed, ${infos} info`); - - if (fails > 0) { - console.log('\n FAILURES:'); - for (const r of results.filter(r => r.status === 'FAIL')) { - console.log(` ❌ ${r.name}: ${r.detail}`); - } - } - - } finally { - await browser.close(); - console.log('\nCleaning up...'); - await deleteSession(sessionA); - await deleteSession(sessionB); - } -} - -main().catch(err => { - console.error('Test failed:', err); - process.exit(1); -}); diff --git a/test/local-echo-tabswitch-test.mjs b/test/local-echo-tabswitch-test.mjs deleted file mode 100644 index b4c181b5..00000000 --- a/test/local-echo-tabswitch-test.mjs +++ /dev/null @@ -1,516 +0,0 @@ -/** - * Local Echo Overlay — Tab Switch Bug Reproduction Test - * - * Reproduces the specific bug: switch tabs, switch back, type a space → - * previous input disappears, cursor moves to start. - * - * Port: 3000 (runs against live dev server) - * - * Usage: node test/local-echo-tabswitch-test.mjs - */ -import { chromium } from 'playwright'; -import { mkdirSync } from 'fs'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; -const SCREENSHOT_DIR = '/tmp/local-echo-tabswitch'; -const results = []; -const jsErrors = []; - -function pass(name) { results.push({ name, ok: true }); console.log(` ✓ PASS: ${name}`); } -function fail(name, detail) { results.push({ name, ok: false, detail }); console.log(` ✗ FAIL: ${name} — ${detail}`); } - -async function createSession(name) { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'shell', name }) - }); - if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForReady(sessionId, timeoutMs = 15000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const resp = await fetch(`${BASE}/api/sessions/${sessionId}`); - if (resp.ok) { - const data = await resp.json(); - if (data.status === 'idle' || data.status === 'busy') return true; - } - await new Promise(r => setTimeout(r, 200)); - } - return false; -} - -async function getOverlayState(page) { - return page.evaluate(() => { - const app = window.app; - if (!app) return null; - const overlay = app._localEchoOverlay; - const overlayEl = overlay?.overlay; - - // Get displayed text from overlay DOM children - let displayedText = ''; - if (overlayEl) { - for (const child of overlayEl.children) { - // Skip cursor element (no inner spans with text or single span with no text) - const spans = child.querySelectorAll('span'); - const text = Array.from(spans).map(s => s.textContent).join(''); - displayedText += text; - } - } - - return { - overlayVisible: overlayEl ? overlayEl.style.display !== 'none' : false, - pendingText: overlay?.pendingText || '', - flushedOffset: overlay?._flushedOffset || 0, - flushedText: overlay?._flushedText || '', - displayedText: displayedText.replace(/\u00a0/g, ' '), // normalize nbsp - localEchoEnabled: app._localEchoEnabled, - activeSessionId: app.activeSessionId, - flushedOffsetsMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, - flushedTextsMap: app._flushedTexts ? Object.fromEntries(app._flushedTexts) : {}, - lastPromptPos: overlay?._lastPromptPos || null, - overlayChildCount: overlayEl?.children?.length || 0, - }; - }); -} - -async function screenshot(page, name) { - const path = `${SCREENSHOT_DIR}/${name}.png`; - await page.screenshot({ path, fullPage: false }); - console.log(` 📸 ${path}`); -} - -async function clickTab(page, sessionId) { - await page.evaluate((id) => { - const tab = document.querySelector(`.session-tab[data-id="${id}"]`); - if (tab) tab.click(); - }, sessionId); -} - -async function focusTerminal(page) { - await page.evaluate(() => { - document.querySelector('.xterm-helper-textarea')?.focus(); - }); - await page.waitForTimeout(200); -} - -async function main() { - try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} - - console.log('=== Local Echo Tab Switch Bug Test ===\n'); - - // Create two shell sessions - console.log('Creating test sessions...'); - const sessionA = await createSession('ts-test-A'); - const sessionB = await createSession('ts-test-B'); - console.log(` Session A: ${sessionA}`); - console.log(` Session B: ${sessionB}`); - - const readyA = await waitForReady(sessionA); - const readyB = await waitForReady(sessionB); - if (!readyA || !readyB) { - console.error('Sessions failed to become ready'); - await deleteSession(sessionA); - await deleteSession(sessionB); - process.exit(1); - } - console.log(' Both sessions ready\n'); - - const browser = await chromium.launch({ - headless: true, - args: ['--window-size=390,844'] - }); - const context = await browser.newContext({ - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15', - isMobile: true, - hasTouch: true, - }); - const page = await context.newPage(); - page.on('console', msg => { if (msg.type() === 'error') jsErrors.push(msg.text()); }); - - // Install instrumentation to trace flushed state clearing - await page.exposeFunction('__traceFlushedClear', (source, data) => { - console.log(` 🔍 TRACE: flushed state cleared from "${source}" — data=${data}`); - }); - - try { - // ── SETUP ── - console.log('Opening Codeman...'); - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(2500); - - // Enable local echo - console.log('Enabling local echo...'); - await page.evaluate(() => { - const key = window.app?.getSettingsStorageKey?.() || 'codeman-app-settings'; - const settings = JSON.parse(localStorage.getItem(key) || '{}'); - settings.localEchoEnabled = true; - localStorage.setItem(key, JSON.stringify(settings)); - if (window.app?._cachedAppSettings) window.app._cachedAppSettings.localEchoEnabled = true; - window.app?._updateLocalEchoState?.(); - }); - await page.waitForTimeout(300); - - // Install tracing hooks inside the page to catch when flushed state is cleared - await page.evaluate(() => { - const app = window.app; - if (!app) return; - - // Patch the onData handler to trace control char clearing - // We can't directly patch the inner function, but we can watch the Maps - const origDelete = Map.prototype.delete; - const flushedOffsets = app._flushedOffsets; - const flushedTexts = app._flushedTexts; - - // Trace overlay.clear() - if (app._localEchoOverlay) { - const origClear = app._localEchoOverlay.clear.bind(app._localEchoOverlay); - app._localEchoOverlay.clear = function() { - const stack = new Error().stack; - // Extract caller info - const callerLine = stack.split('\n')[2]?.trim() || 'unknown'; - console.warn(`[TRACE] overlay.clear() called from: ${callerLine}`); - window.__localEchoClearLog = window.__localEchoClearLog || []; - window.__localEchoClearLog.push({ - time: Date.now(), - source: 'overlay.clear()', - caller: callerLine, - hadFlushed: this._flushedOffset > 0, - hadPending: this.pendingText.length > 0, - }); - return origClear(); - }; - } - }); - - // Switch to session A - console.log('Switching to Session A...'); - await clickTab(page, sessionA); - await page.waitForTimeout(2000); - await focusTerminal(page); - - let state = await getOverlayState(page); - console.log(` Local echo enabled: ${state?.localEchoEnabled}`); - if (!state?.localEchoEnabled) { - fail('Local echo not enabled', `localEchoEnabled=${state?.localEchoEnabled}`); - return; - } - pass('Local echo enabled'); - - // ── STEP 1: Type text in Session A ── - console.log('\n--- Step 1: Type "hello world" in Session A ---'); - await page.keyboard.type('hello world', { delay: 50 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '01-typed-in-A'); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` overlayVisible: ${state?.overlayVisible}`); - console.log(` displayedText: "${state?.displayedText}"`); - - if (state?.pendingText === 'hello world') { - pass('Text typed in Session A'); - } else { - fail('Text in Session A', `expected "hello world", got "${state?.pendingText}"`); - } - - // ── STEP 2: Switch to Session B ── - console.log('\n--- Step 2: Switch to Session B ---'); - // Clear trace log - await page.evaluate(() => { window.__localEchoClearLog = []; }); - - await clickTab(page, sessionB); - await page.waitForTimeout(2500); - await focusTerminal(page); - - state = await getOverlayState(page); - await screenshot(page, '02-switched-to-B'); - console.log(` activeSession: ${state?.activeSessionId}`); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`); - console.log(` flushedTextsMap: ${JSON.stringify(state?.flushedTextsMap)}`); - - if (state?.activeSessionId === sessionB) { - pass('Switched to Session B'); - } else { - fail('Tab switch to B', `activeSession=${state?.activeSessionId}`); - } - - // Check that A's text was stored in flushed maps - const flushedForA = state?.flushedOffsetsMap?.[sessionA]; - if (flushedForA === 11) { // "hello world" = 11 chars - pass(`Session A text flushed (${flushedForA} chars)`); - } else { - fail('Flushed offset for A', `expected 11, got ${flushedForA}`); - } - - // ── STEP 3: Type in Session B ── - console.log('\n--- Step 3: Type "typing in B" in Session B ---'); - await page.keyboard.type('typing in B', { delay: 50 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '03-typed-in-B'); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`); - - if (state?.pendingText === 'typing in B') { - pass('Text typed in Session B'); - } else { - fail('Text in Session B', `expected "typing in B", got "${state?.pendingText}"`); - } - - // Verify A's flushed state is still in the Maps - if (state?.flushedOffsetsMap?.[sessionA] === 11) { - pass('Session A flushed state preserved while on B'); - } else { - fail('A flushed state while on B', `expected 11, got ${state?.flushedOffsetsMap?.[sessionA]}`); - } - - // ── STEP 4: Switch back to Session A ── - console.log('\n--- Step 4: Switch back to Session A ---'); - await page.evaluate(() => { window.__localEchoClearLog = []; }); - - await clickTab(page, sessionA); - await page.waitForTimeout(3000); // Wait for full buffer load - - state = await getOverlayState(page); - await screenshot(page, '04-back-to-A-before-typing'); - console.log(` activeSession: ${state?.activeSessionId}`); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` flushedText: "${state?.flushedText}"`); - console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`); - console.log(` overlayVisible: ${state?.overlayVisible}`); - console.log(` lastPromptPos: ${JSON.stringify(state?.lastPromptPos)}`); - console.log(` displayedText: "${state?.displayedText}"`); - - // Check trace log - const clearLog = await page.evaluate(() => window.__localEchoClearLog || []); - console.log(` Clear log entries: ${clearLog.length}`); - for (const entry of clearLog) { - console.log(` - ${entry.source} @ ${entry.caller} (hadFlushed=${entry.hadFlushed}, hadPending=${entry.hadPending})`); - } - - if (state?.activeSessionId === sessionA) { - pass('Switched back to Session A'); - } else { - fail('Tab switch back to A', `activeSession=${state?.activeSessionId}`); - } - - // KEY CHECK: flushed state should be restored - if (state?.flushedOffset === 11) { - pass('Flushed offset restored on overlay'); - } else { - fail('Flushed offset on overlay', `expected 11, got ${state?.flushedOffset}`); - } - - if (state?.flushedText === 'hello world') { - pass('Flushed text restored on overlay'); - } else { - fail('Flushed text on overlay', `expected "hello world", got "${state?.flushedText}"`); - } - - // ── STEP 5: THE BUG — Type a space after switching back ── - console.log('\n--- Step 5: Type a SPACE after returning to Session A ---'); - await focusTerminal(page); - - // Record state RIGHT BEFORE typing - const preTypeState = await getOverlayState(page); - console.log(` PRE-TYPE state:`); - console.log(` pendingText: "${preTypeState?.pendingText}"`); - console.log(` flushedOffset: ${preTypeState?.flushedOffset}`); - console.log(` flushedText: "${preTypeState?.flushedText}"`); - console.log(` overlayVisible: ${preTypeState?.overlayVisible}`); - console.log(` localEchoEnabled: ${preTypeState?.localEchoEnabled}`); - - await page.keyboard.type(' ', { delay: 0 }); // Type single space - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '05-typed-space-after-return'); - console.log(` POST-TYPE state:`); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` flushedText: "${state?.flushedText}"`); - console.log(` overlayVisible: ${state?.overlayVisible}`); - console.log(` displayedText: "${state?.displayedText}"`); - console.log(` overlayChildCount: ${state?.overlayChildCount}`); - - // THE CRITICAL ASSERTION: flushed text should still be tracked - if (state?.flushedOffset === 11) { - pass('Flushed offset preserved after typing space'); - } else { - fail('Flushed offset after space', `expected 11, got ${state?.flushedOffset}`); - } - - // The displayed text should include BOTH flushed text AND new space - const expectedDisplay = 'hello world '; - if (state?.displayedText?.startsWith('hello world')) { - pass('Displayed text includes flushed + new text'); - } else { - fail('Displayed text', `expected starts with "hello world", got "${state?.displayedText}"`); - } - - if (state?.overlayVisible) { - pass('Overlay visible after typing'); - } else { - fail('Overlay visibility', 'overlay is hidden after typing'); - } - - // ── STEP 6: Type more characters ── - console.log('\n--- Step 6: Type "more" after the space ---'); - await page.keyboard.type('more', { delay: 50 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '06-typed-more-after-space'); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` displayedText: "${state?.displayedText}"`); - - if (state?.pendingText === ' more') { - pass('Pending text is " more"'); - } else { - fail('Pending text after more typing', `expected " more", got "${state?.pendingText}"`); - } - - const expectedFullDisplay = 'hello world more'; - if (state?.displayedText?.includes('hello world') && state?.displayedText?.includes('more')) { - pass('Full displayed text includes flushed + all new text'); - } else { - fail('Full display', `expected "${expectedFullDisplay}", got "${state?.displayedText}"`); - } - - // ── STEP 7: Backspace through new text into flushed text ── - console.log('\n--- Step 7: Backspace through new and into flushed text ---'); - // Delete " more" (5 chars) + 3 from flushed - for (let i = 0; i < 8; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - } - await page.waitForTimeout(300); - - state = await getOverlayState(page); - await screenshot(page, '07-after-backspace-into-flushed'); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` flushedText: "${state?.flushedText}"`); - console.log(` displayedText: "${state?.displayedText}"`); - - // Should have deleted 5 pending + 3 flushed = flushedOffset 8 - if (state?.pendingText === '') { - pass('Pending text empty after backspace'); - } else { - fail('Pending text after backspace', `expected "", got "${state?.pendingText}"`); - } - - if (state?.flushedOffset === 8) { - pass('Flushed offset correctly decremented (11 - 3 = 8)'); - } else { - fail('Flushed offset after backspace', `expected 8, got ${state?.flushedOffset}`); - } - - // ── STEP 8: Submit with Enter ── - console.log('\n--- Step 8: Press Enter to submit ---'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - - state = await getOverlayState(page); - await screenshot(page, '08-after-enter'); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` overlayVisible: ${state?.overlayVisible}`); - console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`); - - if (!state?.overlayVisible && state?.pendingText === '' && state?.flushedOffset === 0) { - pass('Overlay fully cleared after Enter'); - } else { - fail('State after Enter', `visible=${state?.overlayVisible}, pending="${state?.pendingText}", flushed=${state?.flushedOffset}`); - } - - if (!state?.flushedOffsetsMap?.[sessionA]) { - pass('Flushed map cleared for session A after Enter'); - } else { - fail('Flushed map after Enter', `still has A: ${state?.flushedOffsetsMap?.[sessionA]}`); - } - - // ── STEP 9: Repeat the cycle — ensure it works multiple times ── - console.log('\n--- Step 9: Second cycle — type, switch, switch back, type ---'); - await page.keyboard.type('second-round', { delay: 40 }); - await page.waitForTimeout(300); - - // Switch to B - await clickTab(page, sessionB); - await page.waitForTimeout(2000); - - // Switch back to A - await clickTab(page, sessionA); - await page.waitForTimeout(3000); - await focusTerminal(page); - - state = await getOverlayState(page); - console.log(` After 2nd cycle — flushedOffset: ${state?.flushedOffset}, flushedText: "${state?.flushedText}"`); - - // Type space + more text - await page.keyboard.type(' again', { delay: 40 }); - await page.waitForTimeout(500); - - state = await getOverlayState(page); - await screenshot(page, '09-second-cycle'); - console.log(` pendingText: "${state?.pendingText}"`); - console.log(` flushedOffset: ${state?.flushedOffset}`); - console.log(` displayedText: "${state?.displayedText}"`); - - if (state?.displayedText?.includes('second-round') && state?.displayedText?.includes('again')) { - pass('Second cycle: flushed + new text displayed correctly'); - } else { - fail('Second cycle display', `expected "second-round again", got "${state?.displayedText}"`); - } - - // ── SUMMARY ── - console.log('\n=== SUMMARY ==='); - const passes = results.filter(r => r.ok).length; - const fails = results.filter(r => !r.ok).length; - console.log(` ${passes} passed, ${fails} failed`); - console.log(` Screenshots: ${SCREENSHOT_DIR}/`); - - if (fails > 0) { - console.log('\n FAILURES:'); - for (const r of results.filter(r => !r.ok)) { - console.log(` ✗ ${r.name}: ${r.detail}`); - } - } - - if (jsErrors.length > 0) { - console.log(`\n JS errors (${jsErrors.length}):`); - for (const e of jsErrors.slice(0, 10)) console.log(` ${e}`); - } - - console.log(`\n Exit code: ${fails > 0 ? 1 : 0}`); - process.exitCode = fails > 0 ? 1 : 0; - - } finally { - await browser.close(); - console.log('\nCleaning up sessions...'); - await deleteSession(sessionA); - await deleteSession(sessionB); - } -} - -main().catch(err => { - console.error('Test failed:', err); - process.exit(1); -}); diff --git a/test/local-echo-user-test.mjs b/test/local-echo-user-test.mjs deleted file mode 100644 index b0b633f8..00000000 --- a/test/local-echo-user-test.mjs +++ /dev/null @@ -1,1106 +0,0 @@ -/** - * Local Echo Overlay — Comprehensive User-Scenario Tests (Mobile-First) - * - * 30 tests across 8 groups (A-H), ~108 screenshots. - * Simulates a real mobile user's daily workflow with heavy visual verification. - * - * Screenshots saved to /tmp/local-echo-user/ with descriptive names. - * Error report written to /tmp/local-echo-user/error-report.md. - * - * Usage: timeout 180 node test/local-echo-user-test.mjs 2>&1 | tee /tmp/user-test-output.txt - */ -import { chromium } from 'playwright'; -import { mkdirSync, writeFileSync } from 'fs'; - -const PORT = 3000; -const BASE = `http://localhost:${PORT}`; -const SCREENSHOT_DIR = '/tmp/local-echo-user'; - -// ── Results & Error Reporting ── -const results = []; -const errorReportEntries = []; - -function pass(id, name) { - results.push({ id, name, ok: true }); - console.log(` ✅ ${id}: ${name}`); -} - -function fail(id, name, expected, actual, stateDump, screenshotName, jsErrors) { - results.push({ id, name, ok: false, expected, actual }); - console.log(` ❌ ${id}: ${name} — expected: ${expected}, actual: ${actual}`); - errorReportEntries.push({ id, name, expected, actual, stateDump, screenshotName, jsErrors }); -} - -function writeErrorReport() { - const lines = ['# Local Echo User Test — Error Report\n']; - for (const e of errorReportEntries) { - lines.push(`## ${e.id}: ${e.name}`); - lines.push(`**Status:** FAIL`); - lines.push(`**Step:** ${e.screenshotName || 'N/A'}`); - lines.push(`**Expected:** ${e.expected}`); - lines.push(`**Actual:** ${e.actual}`); - if (e.stateDump) { - lines.push('**State dump:**'); - for (const [k, v] of Object.entries(e.stateDump)) { - lines.push(` ${k}: ${JSON.stringify(v)}`); - } - } - if (e.screenshotName) lines.push(`**Screenshot:** ${e.screenshotName}.png`); - lines.push(`**JS errors:** ${e.jsErrors?.length ? e.jsErrors.join('; ') : 'none'}`); - lines.push(''); - } - const passed = results.filter(r => r.ok).length; - const failed = results.filter(r => !r.ok).length; - const failedIds = results.filter(r => !r.ok).map(r => r.id); - const failedScreenshots = errorReportEntries.map(e => e.screenshotName).filter(Boolean); - lines.push('## Summary'); - lines.push(`- Total: ${results.length} assertions`); - lines.push(`- Passed: ${passed}`); - lines.push(`- Failed: ${failed}`); - lines.push(`- Failed tests: ${failedIds.join(', ') || 'none'}`); - lines.push(`- Screenshots with failures: ${failedScreenshots.map(s => s + '.png').join(', ') || 'none'}`); - writeFileSync(`${SCREENSHOT_DIR}/error-report.md`, lines.join('\n')); -} - -// ── Helpers ── -async function createSession(name) { - const resp = await fetch(`${BASE}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'claude', name }) - }); - if (!resp.ok) throw new Error(`Failed to create '${name}': ${resp.status}`); - const data = await resp.json(); - return data.session?.id || data.id; -} - -async function deleteSession(id) { - await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); -} - -async function waitForReady(id, ms = 15000) { - const start = Date.now(); - while (Date.now() - start < ms) { - const r = await fetch(`${BASE}/api/sessions/${id}`); - if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; } - await new Promise(r => setTimeout(r, 300)); - } - return false; -} - -async function getState(page) { - return page.evaluate(() => { - const app = window.app; - if (!app) return null; - const ov = app._localEchoOverlay; - const buf = app.terminal?.buffer?.active; - - // Cell dimensions - let cellW = 0, cellH = 0; - try { - const dims = app.terminal._core._renderService.dimensions; - cellW = dims.css.cell.width; - cellH = dims.css.cell.height; - } catch {} - - // Children (line divs) - const children = []; - if (ov?.overlay) { - for (const c of ov.overlay.children) { - const spans = c.querySelectorAll('span'); - const text = Array.from(spans).map(s => s.textContent).join(''); - children.push({ - text, - left: c.style.left, - top: c.style.top, - width: c.style.width, - bgColor: c.style.backgroundColor, - spanCount: spans.length, - }); - } - } - - // Cursor block: a direct child of overlay with position:absolute, - // display:inline-block, empty text, and a backgroundColor (cursor color). - // It's appended as the last child of this.overlay (not inside a line div). - let cursor = { exists: false, left: null, top: null, width: null, height: null, bgColor: null }; - if (ov?.overlay) { - for (const child of ov.overlay.children) { - if (child.tagName === 'SPAN' - && child.style.position === 'absolute' - && child.style.display === 'inline-block' - && child.textContent === '') { - cursor = { - exists: true, - left: parseFloat(child.style.left) || 0, - top: parseFloat(child.style.top) || 0, - width: parseFloat(child.style.width) || child.offsetWidth, - height: parseFloat(child.style.height) || child.offsetHeight, - bgColor: child.style.backgroundColor, - }; - break; - } - } - } - - // Prompt line from buffer (bottom-up scan) - let promptLine = null; - if (buf) { - for (let row = app.terminal.rows - 1; row >= 0; row--) { - const line = buf.getLine(buf.viewportY + row); - if (!line) continue; - const text = line.translateToString(true); - if (text.includes('\u276f')) { - promptLine = { text, row, col: text.lastIndexOf('\u276f') }; - break; - } - } - } - - return { - visible: ov?.overlay?.style.display !== 'none', - text: ov?.pendingText || '', - flushed: ov?._flushedOffset || 0, - flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, - children, - cursor, - promptLine, - cellW, - cellH, - activeId: app.activeSessionId, - enabled: app._localEchoEnabled, - }; - }); -} - -async function focus(page) { - await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus()); - await page.waitForTimeout(200); -} - -async function switchTab(page, id) { - await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id); - await page.waitForTimeout(1500); -} - -async function waitForPrompt(page, timeoutMs = 15000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const st = await getState(page); - if (st?.promptLine) return true; - await page.waitForTimeout(200); - } - return false; -} - -async function sc(page, name) { - const path = `${SCREENSHOT_DIR}/${name}.png`; - await page.screenshot({ path }); - console.log(` 📸 ${name}.png`); -} - -async function cleanPrompt(page) { - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(500); - await focus(page); - await waitForPrompt(page, 5000); -} - -// ── Main ── -async function main() { - try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} - - console.log('=== Local Echo — Comprehensive User-Scenario Tests ===\n'); - - // Create two sessions - const sA = await createSession('user-test-A'); - const sB = await createSession('user-test-B'); - console.log(` A: ${sA}\n B: ${sB}`); - if (!await waitForReady(sA) || !await waitForReady(sB)) { - await deleteSession(sA); await deleteSession(sB); - throw new Error('Sessions not ready'); - } - console.log(' Both sessions ready\n'); - - const browser = await chromium.launch({ headless: true }); - const ctx = await browser.newContext({ - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', - isMobile: true, hasTouch: true, - }); - const page = await ctx.newPage(); - const jsErrors = []; - page.on('pageerror', err => jsErrors.push(err.message)); - - try { - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 15000 }); - await page.waitForTimeout(3000); - - // Enable local echo - await page.evaluate(() => { - const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); - s.localEchoEnabled = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(s)); - window.app?._updateLocalEchoState?.(); - }); - - // Select tab A, wait for prompt - await switchTab(page, sA); - await focus(page); - if (!await waitForPrompt(page)) throw new Error('No prompt on A'); - - // ════════════════════════════════════════════════════════════════ - // GROUP A: Fresh Typing (4 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP A: Fresh Typing ══'); - - // A01: Type single char - console.log('\n--- A01: Type single char ---'); - await sc(page, 'A01a-clean-prompt'); - await page.keyboard.type('x', { delay: 30 }); - await page.waitForTimeout(300); - let st = await getState(page); - await sc(page, 'A01b-typed-x'); - if (st.visible && st.text === 'x' && st.flushed === 0) - pass('A01', 'Type single char'); - else - fail('A01', 'Type single char', 'visible=true, text="x", flushed=0', - `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'A01b-typed-x', jsErrors); - await cleanPrompt(page); - - // A02: Type a word - console.log('\n--- A02: Type a word ---'); - await sc(page, 'A02a-clean-prompt'); - await page.keyboard.type('h', { delay: 60 }); - await page.waitForTimeout(200); - await sc(page, 'A02b-after-h'); - await page.keyboard.type('ello', { delay: 60 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'A02c-after-hello'); - if (st.text === 'hello') - pass('A02', 'Type a word'); - else - fail('A02', 'Type a word', 'text="hello"', `text="${st.text}"`, st, 'A02c-after-hello', jsErrors); - await cleanPrompt(page); - - // A03: Type then backspace one char - console.log('\n--- A03: Type then backspace one char ---'); - await page.keyboard.type('hello', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'A03a-typed-hello'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'A03b-after-backspace'); - if (st.text === 'hell') - pass('A03', 'Backspace one char'); - else - fail('A03', 'Backspace one char', 'text="hell"', `text="${st.text}"`, st, 'A03b-after-backspace', jsErrors); - await cleanPrompt(page); - - // A04: Type then backspace ALL chars - console.log('\n--- A04: Type then backspace ALL ---'); - await page.keyboard.type('abc', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'A04a-typed-abc'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'A04b-after-1-backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'A04c-after-2-backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'A04d-after-3-backspace'); - if (!st.visible && st.text === '' && st.flushed === 0) - pass('A04', 'Backspace ALL hides overlay'); - else - fail('A04', 'Backspace ALL hides overlay', 'visible=false, text="", flushed=0', - `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'A04d-after-3-backspace', jsErrors); - - // ════════════════════════════════════════════════════════════════ - // GROUP B: Tab Switch Basics (5 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP B: Tab Switch Basics ══'); - - // B05: Type on A, switch to B, check B is clean - console.log('\n--- B05: Type on A, switch to B ---'); - await cleanPrompt(page); - await page.keyboard.type('hello world', { delay: 30 }); - await page.waitForTimeout(300); - await sc(page, 'B05a-typed-on-A'); - await switchTab(page, sB); - st = await getState(page); - await sc(page, 'B05b-on-B'); - if (st.text === '' && (st.flushedMap[sA] === 11 || st.flushedMap[sA] > 0)) - pass('B05', 'Switch to B clears overlay, A flushed'); - else - fail('B05', 'Switch to B clears overlay', 'text="", flushedMap[A]=11', - `text="${st.text}", flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'B05b-on-B', jsErrors); - - // B06: Type on A, switch to B, back to A — flushed restored + overlay visible - console.log('\n--- B06: Round-trip flushed restore ---'); - await switchTab(page, sA); - await focus(page); - await cleanPrompt(page); - await page.keyboard.type('testing', { delay: 30 }); - await page.waitForTimeout(300); - await sc(page, 'B06a-typed-on-A'); - await switchTab(page, sB); - await sc(page, 'B06b-on-B'); - await switchTab(page, sA); - await focus(page); - st = await getState(page); - await sc(page, 'B06c-back-on-A'); - // After fix: overlay stays VISIBLE with flushed text (no pending), so user - // can see what they typed and backspace into it with visual feedback. - if (st.text === '' && st.flushed === 7 && st.visible) - pass('B06', 'Flushed restored + overlay visible'); - else - fail('B06', 'Flushed restored + overlay visible', 'text="", flushed=7, visible=true', - `text="${st.text}", flushed=${st.flushed}, visible=${st.visible}`, st, 'B06c-back-on-A', jsErrors); - - // B07: Type on A, switch, back, type SPACE — no text erasure - console.log('\n--- B07: Space after tab switch ---'); - await cleanPrompt(page); - await page.keyboard.type('test', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'B07a-typed-test'); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await sc(page, 'B07b-back-on-A'); - await page.keyboard.type(' ', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'B07c-after-space'); - const b07childText = st.children?.[0]?.text || ''; - if (st.text === ' ' && st.flushed === 4 && b07childText.includes('test')) - pass('B07', 'Space after switch — no erasure'); - else - fail('B07', 'Space after switch', 'text=" ", flushed=4, child contains "test"', - `text="${st.text}", flushed=${st.flushed}, child="${b07childText}"`, st, 'B07c-after-space', jsErrors); - - // B08: Type on A, switch, back, type more chars - console.log('\n--- B08: More chars after round-trip ---'); - await cleanPrompt(page); - await page.keyboard.type('fix', { delay: 30 }); - await page.waitForTimeout(200); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await sc(page, 'B08a-back-on-A'); - await page.keyboard.type(' bug', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'B08b-after-more-chars'); - if (st.text === ' bug' && st.flushed === 3) - pass('B08', 'Type more chars after round-trip'); - else - fail('B08', 'Type more after round-trip', 'text=" bug", flushed=3', - `text="${st.text}", flushed=${st.flushed}`, st, 'B08b-after-more-chars', jsErrors); - - // B09: Type on A, switch, back, backspace 1 char from flushed - // After fix: overlay stays visible with flushed text, backspace gives instant feedback - console.log('\n--- B09: Backspace into flushed ---'); - await cleanPrompt(page); - await page.keyboard.type('hello', { delay: 30 }); - await page.waitForTimeout(200); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await sc(page, 'B09a-back-with-flushed'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'B09b-after-backspace'); - const b09childText = st.children?.[0]?.text || ''; - if (st.flushed === 4 && st.visible && b09childText === 'hell') - pass('B09', 'Backspace into flushed with visual feedback'); - else - fail('B09', 'Backspace into flushed', 'flushed=4, visible=true, child="hell"', - `flushed=${st.flushed}, visible=${st.visible}, child="${b09childText}"`, st, 'B09b-after-backspace', jsErrors); - - // ════════════════════════════════════════════════════════════════ - // GROUP C: Tab Switch + Editing (5 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP C: Tab Switch + Editing ══'); - - // C10: Type, switch, back, backspace 3 + type new text - // After fix: overlay renders correctly using stored flushed text - console.log('\n--- C10: Backspace flushed + retype ---'); - await cleanPrompt(page); - await page.keyboard.type('old text', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'C10a-typed'); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - for (let i = 0; i < 3; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - } - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'C10b-after-3-backspace'); - // After 3 backspaces: flushed "old text" (8) - 3 = "old t" (5) - const c10midChild = st.children?.[0]?.text || ''; - if (st.flushed === 5 && c10midChild === 'old t') - pass('C10a', 'Backspace flushed shows correct remaining text'); - else - fail('C10a', 'Backspace flushed text', 'flushed=5, child="old t"', - `flushed=${st.flushed}, child="${c10midChild}"`, st, 'C10b-after-3-backspace', jsErrors); - await page.keyboard.type('new', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'C10c-after-retype'); - const c10childText = st.children?.[0]?.text || ''; - if (st.flushed === 5 && st.text === 'new' && c10childText === 'old tnew') - pass('C10b', 'Retype after flushed backspace renders correctly'); - else - fail('C10b', 'Retype after flushed backspace', 'flushed=5, text="new", child="old tnew"', - `flushed=${st.flushed}, text="${st.text}", child="${c10childText}"`, st, 'C10c-after-retype', jsErrors); - - // C11: Type, switch, back, delete ALL flushed - console.log('\n--- C11: Delete all flushed ---'); - await cleanPrompt(page); - await page.keyboard.type('abcde', { delay: 30 }); - await page.waitForTimeout(200); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - for (let i = 0; i < 5; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - } - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'C11a-after-full-backspace'); - // Extra backspaces — should be no-op - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'C11b-extra-backspace-noop'); - if (!st.visible && st.flushed === 0) - pass('C11', 'Delete all flushed'); - else - fail('C11', 'Delete all flushed', 'visible=false, flushed=0', - `visible=${st.visible}, flushed=${st.flushed}`, st, 'C11b-extra-backspace-noop', jsErrors); - - // C12: Type, switch, back, delete ALL + type fresh - console.log('\n--- C12: Delete all + type fresh ---'); - await cleanPrompt(page); - await page.keyboard.type('remove', { delay: 30 }); - await page.waitForTimeout(200); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - for (let i = 0; i < 8; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(50); - } - await page.waitForTimeout(200); - await sc(page, 'C12a-fully-deleted'); - await page.keyboard.type('brand new', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'C12b-fresh-text'); - if (st.text === 'brand new' && st.flushed === 0 && st.visible) - pass('C12', 'Fresh text after full delete'); - else - fail('C12', 'Fresh text after full delete', 'text="brand new", flushed=0, visible=true', - `text="${st.text}", flushed=${st.flushed}, visible=${st.visible}`, st, 'C12b-fresh-text', jsErrors); - - // C13: Type on A, switch B, type on B, back to A — no cross-talk - console.log('\n--- C13: No cross-talk between tabs ---'); - await cleanPrompt(page); - await page.keyboard.type('alpha', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'C13a-alpha-on-A'); - await switchTab(page, sB); - await focus(page); - await waitForPrompt(page, 5000); - await page.keyboard.type('beta', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'C13b-beta-on-B'); - await switchTab(page, sA); - await focus(page); - st = await getState(page); - await sc(page, 'C13c-back-on-A'); - if (st.flushedMap[sA] === 5 && st.text === '') - pass('C13', 'No cross-talk between tabs'); - else - fail('C13', 'No cross-talk', 'flushedMap[A]=5, text=""', - `flushedMap=${JSON.stringify(st.flushedMap)}, text="${st.text}"`, st, 'C13c-back-on-A', jsErrors); - - // C14: Both sessions maintain state across double round-trip - console.log('\n--- C14: Double round-trip state ---'); - await cleanPrompt(page); - // Clean B first - await switchTab(page, sB); - await focus(page); - await cleanPrompt(page); - await switchTab(page, sA); - await focus(page); - await cleanPrompt(page); - - await page.keyboard.type('aaa', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'C14a-aaa-on-A'); - await switchTab(page, sB); - await focus(page); - await page.keyboard.type('bbb', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'C14b-bbb-on-B'); - await switchTab(page, sA); - await focus(page); - st = await getState(page); - await sc(page, 'C14c-back-A'); - const c14aFlushed1 = st.flushed; - await page.keyboard.type('+', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'C14d-plus-on-A'); - await switchTab(page, sB); - await focus(page); - st = await getState(page); - await sc(page, 'C14e-back-B'); - const c14bFlushed = st.flushed; - await page.keyboard.type('+', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'C14f-plus-on-B'); - st = await getState(page); - if (c14aFlushed1 >= 3 && c14bFlushed >= 3) - pass('C14', 'Double round-trip state preserved'); - else - fail('C14', 'Double round-trip', 'flushedMap[A]>=3, flushedMap[B]>=3', - `A flushed=${c14aFlushed1}, B flushed=${c14bFlushed}`, st, 'C14f-plus-on-B', jsErrors); - - // ════════════════════════════════════════════════════════════════ - // GROUP D: Enter / Submit (3 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP D: Enter / Submit ══'); - - // D15: Type then Enter clears everything - console.log('\n--- D15: Enter clears overlay ---'); - await switchTab(page, sA); - await focus(page); - await cleanPrompt(page); - await page.keyboard.type('echo test-d15', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'D15a-before-enter'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - await sc(page, 'D15b-after-enter'); - await page.waitForTimeout(1000); - st = await getState(page); - await sc(page, 'D15c-settled'); - if (!st.visible && st.text === '' && st.flushed === 0) - pass('D15', 'Enter clears overlay'); - else - fail('D15', 'Enter clears overlay', 'visible=false, text="", flushed=0', - `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'D15c-settled', jsErrors); - - // D16: Type, switch (flushed), back, Enter - console.log('\n--- D16: Enter after flushed round-trip ---'); - await page.waitForTimeout(3000); // Wait for response - await focus(page); - await waitForPrompt(page, 5000); - await page.keyboard.type('flush-test-d16', { delay: 30 }); - await page.waitForTimeout(200); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - st = await getState(page); - await sc(page, 'D16a-flushed-state'); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - st = await getState(page); - await sc(page, 'D16b-after-enter'); - if (st.flushed === 0 && !st.flushedMap[sA]) - pass('D16', 'Enter clears flushed state'); - else - fail('D16', 'Enter clears flushed', 'flushed=0, flushedMap[A]=undefined', - `flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'D16b-after-enter', jsErrors); - - // D17: Type, Enter, wait for response, type on new prompt - console.log('\n--- D17: Type on new prompt after response ---'); - await page.waitForTimeout(4000); // Wait for response - await focus(page); - await waitForPrompt(page, 8000); - await sc(page, 'D17a-before-enter'); - // Don't submit — D15/D16 already submitted. Just type on the new prompt. - await page.keyboard.type('second input', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'D17c-second-input'); - if (st.text === 'second input' && st.flushed === 0) - pass('D17', 'Type on new prompt after response'); - else - fail('D17', 'New prompt typing', 'text="second input", flushed=0', - `text="${st.text}", flushed=${st.flushed}`, st, 'D17c-second-input', jsErrors); - await cleanPrompt(page); - - // ════════════════════════════════════════════════════════════════ - // GROUP E: Ctrl+C / Cancel (3 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP E: Ctrl+C / Cancel ══'); - - // E18: Type then Ctrl+C clears overlay - console.log('\n--- E18: Ctrl+C clears overlay ---'); - await page.keyboard.type('echo test-e18', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'E18a-before-ctrlc'); - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(500); - st = await getState(page); - await sc(page, 'E18b-after-ctrlc'); - if (!st.visible && st.text === '' && st.flushed === 0) - pass('E18', 'Ctrl+C clears overlay'); - else - fail('E18', 'Ctrl+C clears overlay', 'visible=false, text="", flushed=0', - `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'E18b-after-ctrlc', jsErrors); - - // E19: Type, switch (flushed), back, Ctrl+C - console.log('\n--- E19: Ctrl+C after flushed ---'); - await focus(page); - await waitForPrompt(page, 5000); - await page.keyboard.type('flush-then-cancel', { delay: 30 }); - await page.waitForTimeout(200); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - st = await getState(page); - await sc(page, 'E19a-flushed-state'); - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(500); - st = await getState(page); - await sc(page, 'E19b-after-ctrlc'); - if (st.flushed === 0 && !st.flushedMap[sA]) - pass('E19', 'Ctrl+C clears flushed state'); - else - fail('E19', 'Ctrl+C flushed clear', 'flushed=0, flushedMap[A]=undefined', - `flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'E19b-after-ctrlc', jsErrors); - - // E20: Ctrl+C on empty — no crash - console.log('\n--- E20: Ctrl+C on empty ---'); - await focus(page); - await waitForPrompt(page, 5000); - const jsErrorsBefore = jsErrors.length; - await sc(page, 'E20a-empty-prompt'); - await page.keyboard.down('Control'); - await page.keyboard.press('c'); - await page.keyboard.up('Control'); - await page.waitForTimeout(500); - st = await getState(page); - await sc(page, 'E20b-after-ctrlc'); - if (!st.visible && jsErrors.length === jsErrorsBefore) - pass('E20', 'Ctrl+C on empty — no crash'); - else - fail('E20', 'Ctrl+C on empty', 'visible=false, no new JS errors', - `visible=${st.visible}, new errors=${jsErrors.length - jsErrorsBefore}`, st, 'E20b-after-ctrlc', jsErrors); - - // ════════════════════════════════════════════════════════════════ - // GROUP F: Cursor Position (3 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP F: Cursor Position ══'); - - // F21: Cursor at end of typed text - console.log('\n--- F21: Cursor at end ---'); - await focus(page); - await waitForPrompt(page, 5000); - await page.keyboard.type('cursor', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'F21a-typed-cursor'); - if (st.cursor.exists) - pass('F21', 'Cursor exists at end of text'); - else - fail('F21', 'Cursor at end', 'cursor.exists=true', - `cursor.exists=${st.cursor.exists}`, st, 'F21a-typed-cursor', jsErrors); - await cleanPrompt(page); - - // F22: Cursor after backspace moves left - console.log('\n--- F22: Cursor moves on backspace ---'); - await page.keyboard.type('abcde', { delay: 30 }); - await page.waitForTimeout(300); - let st1 = await getState(page); - await sc(page, 'F22a-five-chars'); - const cursorLeftBefore = st1.cursor.left; - await page.keyboard.press('Backspace'); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'F22b-after-backspace'); - if (st.cursor.exists && st.cursor.left < cursorLeftBefore) - pass('F22', 'Cursor moves left on backspace'); - else - fail('F22', 'Cursor moves left', `cursor.left < ${cursorLeftBefore}`, - `cursor.left=${st.cursor.left}`, st, 'F22b-after-backspace', jsErrors); - await cleanPrompt(page); - - // F23: Cursor after tab switch + retype - console.log('\n--- F23: Cursor after flushed + pending ---'); - await page.keyboard.type('hi', { delay: 30 }); - await page.waitForTimeout(200); - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await page.keyboard.type('!!', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'F23a-flushed-plus-pending'); - // Cursor should exist — that's the key assertion - if (st.cursor.exists) - pass('F23', 'Cursor correct after flushed + pending'); - else - fail('F23', 'Cursor after flushed+pending', 'cursor.exists=true', - `cursor.exists=${st.cursor.exists}`, st, 'F23a-flushed-plus-pending', jsErrors); - await cleanPrompt(page); - - // ════════════════════════════════════════════════════════════════ - // GROUP G: Visual Consistency (4 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP G: Visual Consistency ══'); - - // G24: Overlay position stable between keystrokes - console.log('\n--- G24: Position stable between keystrokes ---'); - await page.keyboard.type('a', { delay: 30 }); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'G24a-first-char'); - const g24posA = st.children?.[0]?.left; - const g24topA = st.children?.[0]?.top; - await page.keyboard.type('b', { delay: 30 }); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'G24b-second-char'); - const g24posB = st.children?.[0]?.left; - const g24topB = st.children?.[0]?.top; - await page.keyboard.type('c', { delay: 30 }); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'G24c-third-char'); - const g24posC = st.children?.[0]?.left; - const g24topC = st.children?.[0]?.top; - if (g24posA === g24posB && g24posB === g24posC && g24topA === g24topB && g24topB === g24topC) - pass('G24', 'Overlay position stable between keystrokes'); - else - fail('G24', 'Position stable', `all same left/top`, - `left: ${g24posA}, ${g24posB}, ${g24posC}; top: ${g24topA}, ${g24topB}, ${g24topC}`, st, 'G24c-third-char', jsErrors); - await cleanPrompt(page); - - // G25: Overlay position stable across tab switch - console.log('\n--- G25: Position stable across tab switch ---'); - await page.keyboard.type('pos test', { delay: 30 }); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'G25a-before-switch'); - const g25leftBefore = st.children?.[0]?.left; - await switchTab(page, sB); - await switchTab(page, sA); - await focus(page); - await page.keyboard.type(' more', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'G25b-after-switch-type'); - const g25leftAfter = st.children?.[0]?.left; - if (g25leftBefore === g25leftAfter) - pass('G25', 'Position stable across tab switch'); - else - fail('G25', 'Position across switch', `left unchanged`, - `before=${g25leftBefore}, after=${g25leftAfter}`, st, 'G25b-after-switch-type', jsErrors); - await cleanPrompt(page); - - // G26: Overlay background covers full width - console.log('\n--- G26: Overlay background width ---'); - await page.keyboard.type('x', { delay: 30 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'G26a-overlay-width'); - const childWidth = st.children?.[0]?.width; - if (childWidth && parseFloat(childWidth) > 0) - pass('G26', 'Overlay has non-zero width'); - else - fail('G26', 'Overlay width', 'width > 0', - `width="${childWidth}"`, st, 'G26a-overlay-width', jsErrors); - await cleanPrompt(page); - - // G27: No JS errors across all tests - console.log('\n--- G27: No JS errors ---'); - await sc(page, 'G27-final-state'); - if (jsErrors.length === 0) - pass('G27', 'No JS errors'); - else - fail('G27', 'No JS errors', '0 errors', - `${jsErrors.length} errors: ${jsErrors.slice(0, 3).join('; ')}`, null, 'G27-final-state', jsErrors); - - // ════════════════════════════════════════════════════════════════ - // GROUP H: Edge Cases (3 tests) - // ════════════════════════════════════════════════════════════════ - console.log('\n══ GROUP H: Edge Cases ══'); - - // H28: Paste multi-char text - console.log('\n--- H28: Paste simulation ---'); - await focus(page); - await waitForPrompt(page, 5000); - await page.evaluate(() => { - window.app?._localEchoOverlay?.appendText('pasted'); - }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'H28a-after-paste'); - if (st.text === 'pasted' && st.visible) - pass('H28', 'Paste appends text'); - else - fail('H28', 'Paste appends text', 'text="pasted", visible=true', - `text="${st.text}", visible=${st.visible}`, st, 'H28a-after-paste', jsErrors); - await cleanPrompt(page); - - // H29: Rapid type-backspace-type - console.log('\n--- H29: Rapid type-backspace-type ---'); - await page.keyboard.type('hello', { delay: 15 }); - await page.waitForTimeout(100); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.waitForTimeout(100); - await page.keyboard.type('p me', { delay: 15 }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'H29a-after-rapid'); - if (st.text === 'help me') - pass('H29', 'Rapid type-backspace-type'); - else - fail('H29', 'Rapid editing', 'text="help me"', - `text="${st.text}"`, st, 'H29a-after-rapid', jsErrors); - await cleanPrompt(page); - - // H30: Page reload clears all state - console.log('\n--- H30: Page reload clears state ---'); - await page.keyboard.type('will be lost', { delay: 30 }); - await page.waitForTimeout(200); - await sc(page, 'H30a-before-reload'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForSelector('.xterm-screen', { timeout: 10000 }); - await page.waitForTimeout(3000); - // Re-enable local echo after reload - await page.evaluate(() => { - window.app?._updateLocalEchoState?.(); - }); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'H30b-after-reload'); - if (st.text === '' && st.flushed === 0 && Object.keys(st.flushedMap).length === 0) - pass('H30', 'Reload clears all state'); - else - fail('H30', 'Reload clears state', 'text="", flushed=0, flushedMap empty', - `text="${st.text}", flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'H30b-after-reload', jsErrors); - - // ══════════════════════════════════════════════════════════════ - // GROUP I: Long Text / Line Wrapping - // ══════════════════════════════════════════════════════════════ - console.log('\n══ GROUP I: Long Text / Line Wrapping ══'); - - // Need a prompt to work with after H30 reload - // Re-select session A and wait for prompt - await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sA); - await page.waitForTimeout(2000); - await page.evaluate(() => { - const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); - s.localEchoEnabled = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(s)); - window.app?._updateLocalEchoState?.(); - }); - await page.waitForTimeout(300); - await focus(page); - await waitForPrompt(page); - - // Get terminal columns to know the wrap point - const termCols = await page.evaluate(() => window.app?.terminal?.cols || 80); - // Prompt uses "❯ " (2 chars), so first line has (cols - 2) available chars - const firstLineCols = termCols - 2; - - // I31: Type text that fills exactly first line (no wrap) - console.log('\n--- I31: Type text that fills first line ---'); - await cleanPrompt(page); - await focus(page); - await waitForPrompt(page); - const fillText = 'A'.repeat(firstLineCols); - await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), fillText); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'I31a-full-first-line'); - if (st.visible && st.text === fillText && st.children.length >= 1) { - pass('I31', 'Full first line renders correctly'); - } else { - fail('I31', 'Full first line', `visible=true, text=${fillText.length} chars, children>=1`, - `visible=${st.visible}, text=${st.text.length} chars, children=${st.children.length}`, st, 'I31a-full-first-line', jsErrors); - } - await cleanPrompt(page); - - // I32: Type text that wraps to second line - console.log('\n--- I32: Type text that wraps to second line ---'); - await focus(page); - await waitForPrompt(page); - const wrapText = 'B'.repeat(firstLineCols + 5); // 5 chars on second line - await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'I32a-wrapped-to-line-2'); - // Should have 2 line divs + 1 cursor span = 3 children - const lineChildren = st.children.filter(c => c.spanCount > 0); - if (st.visible && st.text === wrapText && lineChildren.length === 2) { - pass('I32', 'Text wraps to second line correctly'); - } else { - fail('I32', 'Text wraps', `visible=true, text=${wrapText.length} chars, 2 line divs`, - `visible=${st.visible}, text=${st.text.length} chars, line divs=${lineChildren.length}`, st, 'I32a-wrapped-to-line-2', jsErrors); - } - - // Check cursor is on the second line - if (st.cursor.exists && st.cursor.top > 0) { - pass('I32c', 'Cursor on second line'); - } else { - fail('I32c', 'Cursor position', 'cursor.top > 0 (second line)', - `cursor.top=${st.cursor.top}`, st, 'I32a-wrapped-to-line-2', jsErrors); - } - await cleanPrompt(page); - - // I33: Type wrapping text, backspace back to first line - console.log('\n--- I33: Wrap then backspace to first line ---'); - await focus(page); - await waitForPrompt(page); - const wrapText2 = 'C'.repeat(firstLineCols + 3); - await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText2); - await page.waitForTimeout(200); - await sc(page, 'I33a-wrapped'); - // Backspace the 3 chars on second line - for (let i = 0; i < 3; i++) { - await page.keyboard.press('Backspace'); - await page.waitForTimeout(30); - } - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'I33b-back-to-one-line'); - const lineChildrenAfter = st.children.filter(c => c.spanCount > 0); - if (st.visible && st.text.length === firstLineCols && lineChildrenAfter.length === 1) { - pass('I33', 'Backspace from second line to first'); - } else { - fail('I33', 'Backspace to first line', `text=${firstLineCols} chars, 1 line div`, - `text=${st.text.length} chars, line divs=${lineChildrenAfter.length}`, st, 'I33b-back-to-one-line', jsErrors); - } - await cleanPrompt(page); - - // I34: Wrapping text survives tab switch - console.log('\n--- I34: Wrapping text after tab switch ---'); - await focus(page); - await waitForPrompt(page); - const wrapLen34 = firstLineCols + 8; - const wrapText3 = 'D'.repeat(wrapLen34); - // Use appendText for instant injection (avoids keyboard timing issues) - await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText3); - await page.waitForTimeout(200); - // Verify text is buffered before switching - st = await getState(page); - await sc(page, 'I34a-wrapped-before-switch'); - console.log(` buffered: text=${st.text.length} chars, visible=${st.visible}`); - // Switch to B - await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sB); - await page.waitForTimeout(1500); - // Switch back to A - await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sA); - await page.waitForTimeout(2500); - st = await getState(page); - await sc(page, 'I34b-after-round-trip'); - // After round-trip, flushed should contain the wrapping text - if (st.flushed >= wrapLen34) { - pass('I34a', 'Flushed wrapping text preserved'); - } else { - fail('I34a', 'Flushed wrap text', `flushed>=${wrapLen34}`, - `flushed=${st.flushed}`, st, 'I34b-after-round-trip', jsErrors); - } - // Overlay should be visible with flushed text - if (st.visible && st.children.length >= 2) { - pass('I34b', 'Flushed wrapping text renders multi-line'); - } else { - fail('I34b', 'Flushed wrap render', 'visible=true, children>=2', - `visible=${st.visible}, children=${st.children.length}`, st, 'I34b-after-round-trip', jsErrors); - } - // Type more to extend the wrap - await focus(page); - await page.keyboard.type('EE', { delay: 30 }); - await page.waitForTimeout(200); - st = await getState(page); - await sc(page, 'I34c-extended-wrap'); - if (st.text === 'EE' && st.flushed >= wrapLen34) { - pass('I34c', 'Extended wrapping after tab switch'); - } else { - fail('I34c', 'Extended wrap', `text="EE", flushed>=${wrapLen34}`, - `text="${st.text}", flushed=${st.flushed}`, st, 'I34c-extended-wrap', jsErrors); - } - await cleanPrompt(page); - - // I35: Very long text (3+ lines) - console.log('\n--- I35: Very long text (3+ lines) ---'); - await focus(page); - await waitForPrompt(page); - const longText = 'F'.repeat(firstLineCols + termCols + 5); // first line + full second + 5 on third - await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), longText); - await page.waitForTimeout(300); - st = await getState(page); - await sc(page, 'I35a-three-lines'); - const longLineChildren = st.children.filter(c => c.spanCount > 0); - if (st.visible && st.text === longText && longLineChildren.length === 3) { - pass('I35', 'Very long text renders 3 lines'); - } else { - fail('I35', '3-line text', `visible=true, text=${longText.length} chars, 3 line divs`, - `visible=${st.visible}, text=${st.text.length} chars, line divs=${longLineChildren.length}`, st, 'I35a-three-lines', jsErrors); - } - await cleanPrompt(page); - - // ════════════════════════════════════════════════════════════════ - // SUMMARY - // ════════════════════════════════════════════════════════════════ - console.log('\n════════════════════════════════════════'); - const passed = results.filter(r => r.ok).length; - const failed = results.filter(r => !r.ok).length; - console.log(` ${passed} passed, ${failed} failed out of ${results.length} assertions`); - console.log(` Screenshots: ${SCREENSHOT_DIR}/`); - - if (failed > 0) { - console.log('\n FAILURES:'); - for (const r of results.filter(r => !r.ok)) { - console.log(` ❌ ${r.id}: ${r.name}`); - } - } - - if (jsErrors.length > 0) { - console.log(`\n JS Errors (${jsErrors.length}):`); - for (const e of jsErrors.slice(0, 5)) console.log(` ⚠️ ${e}`); - } - - console.log('════════════════════════════════════════'); - - } finally { - // Write error report - writeErrorReport(); - console.log(`\n Error report: ${SCREENSHOT_DIR}/error-report.md`); - - await browser.close(); - console.log('\nCleaning up sessions...'); - await deleteSession(sA); - await deleteSession(sB); - console.log('Done.\n'); - } -} - -main().catch(err => { console.error('Test failed:', err); process.exit(1); }); diff --git a/test/manual/test-bg-deep.mjs b/test/manual/test-bg-deep.mjs deleted file mode 100644 index 4e00ef33..00000000 --- a/test/manual/test-bg-deep.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); -await page.locator('.session-tab').first().click(); -await page.waitForTimeout(4000); - -// Check ALL background colors in the xterm rendering stack -const bgInfo = await page.evaluate(() => { - const term = window.app?.terminal; - if (!term) return { error: 'no terminal' }; - - const el = term.element; - if (!el) return { error: 'no element' }; - - const results = {}; - - // Walk up from xterm-screen to find who provides the background - const screen = el.querySelector('.xterm-screen'); - const viewport = el.querySelector('.xterm-viewport'); - const rows = el.querySelector('.xterm-rows'); - - const elements = { - '.xterm (term.element)': el, - '.xterm-viewport': viewport, - '.xterm-screen': screen, - '.xterm-rows': rows, - }; - - // Also check parent elements - let parent = el.parentElement; - let depth = 0; - while (parent && depth < 5) { - elements[`parent-${depth} (${parent.tagName}.${parent.className?.split(' ')[0] || ''})`] = parent; - parent = parent.parentElement; - depth++; - } - - for (const [name, elem] of Object.entries(elements)) { - if (!elem) { results[name] = 'not found'; continue; } - const cs = getComputedStyle(elem); - results[name] = { - background: cs.background?.slice(0, 80), - backgroundColor: cs.backgroundColor, - inlineStyle: elem.style.backgroundColor || elem.style.background || '(none)', - }; - } - - // Also check if xterm-viewport has inline style set by xterm.js - if (viewport) { - results['viewport-inline-full'] = viewport.style.cssText?.slice(0, 200); - } - - // Theme config - results.theme = term.options?.theme; - - // Overlay bg - results.overlayBg = window.app?._localEchoOverlay?._font?.backgroundColor; - - return results; -}); - -console.log(JSON.stringify(bgInfo, null, 2)); -await browser.close(); diff --git a/test/manual/test-bg-mismatch.mjs b/test/manual/test-bg-mismatch.mjs deleted file mode 100644 index 43583183..00000000 --- a/test/manual/test-bg-mismatch.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); -await page.locator('.session-tab').first().click(); -await page.waitForTimeout(4000); - -// Sample the ACTUAL canvas pixel color at an empty area -const colorInfo = await page.evaluate(() => { - const term = window.app?.terminal; - if (!term) return { error: 'no terminal' }; - - // Find the WebGL canvas - const screen = term.element?.querySelector('.xterm-screen'); - const canvases = screen?.querySelectorAll('canvas'); - - const results = {}; - - for (const canvas of canvases || []) { - const ctx = canvas.getContext('2d') || canvas.getContext('webgl') || canvas.getContext('webgl2'); - const ctxType = ctx?.constructor?.name; - - if (ctx && (ctxType === 'CanvasRenderingContext2D')) { - // 2D context — can read pixels directly - try { - const pixel = ctx.getImageData(10, 10, 1, 1).data; - results['2d'] = { - r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3], - hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''), - }; - } catch (e) { - results['2d'] = { error: e.message }; - } - } else if (ctx) { - // WebGL context — use readPixels - try { - const gl = ctx; - const pixel = new Uint8Array(4); - // Read from an empty area (bottom-left corner, row 0 col 0) - gl.readPixels(10, canvas.height - 10, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); - results['webgl'] = { - r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3], - hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''), - }; - } catch (e) { - results['webgl'] = { error: e.message }; - } - } - } - - // Also check what the terminal theme says - results.theme = { - background: term.options?.theme?.background, - foreground: term.options?.theme?.foreground, - }; - - // Check the overlay's configured background - const overlay = window.app?._localEchoOverlay; - results.overlayBg = overlay?._font?.backgroundColor; - - // Check xterm-rows computed style - const rows = term.element?.querySelector('.xterm-rows'); - if (rows) { - results.rowsBg = getComputedStyle(rows).backgroundColor; - } - - // Check .xterm element bg - const xtermEl = term.element; - if (xtermEl) { - results.xtermBg = getComputedStyle(xtermEl).backgroundColor; - } - - // Canvas count and types - results.canvasCount = canvases?.length; - results.canvasClasses = Array.from(canvases || []).map(c => c.className); - - return results; -}); - -console.log(JSON.stringify(colorInfo, null, 2)); -await browser.close(); -console.log('Done'); diff --git a/test/manual/test-dims.mjs b/test/manual/test-dims.mjs deleted file mode 100644 index 16045169..00000000 --- a/test/manual/test-dims.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); - -const firstTab = page.locator('.session-tab').first(); -await firstTab.click(); -await page.waitForTimeout(5000); - -const info = await page.evaluate(() => { - const term = window.app?.terminal; - if (!term) return { error: 'no app.terminal' }; - - const core = term._core; - const renderService = core?._renderService; - const dims = renderService?.dimensions; - - if (!dims) return { error: 'no dimensions', hasCore: !!core, hasRenderService: !!renderService }; - - return { - css: { - cell: dims.css?.cell, - char: dims.css?.char, - canvas: dims.css?.canvas, - }, - device: { - cell: dims.device?.cell, - char: dims.device?.char, - canvas: dims.device?.canvas, - }, - topLevelKeys: Object.keys(dims), - cssKeys: dims.css ? Object.keys(dims.css) : [], - deviceKeys: dims.device ? Object.keys(dims.device) : [], - cssAllDetail: dims.css ? Object.fromEntries( - Object.entries(dims.css).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v]) - ) : null, - deviceAllDetail: dims.device ? Object.fromEntries( - Object.entries(dims.device).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v]) - ) : null, - dpr: window.devicePixelRatio, - }; -}); - -console.log(JSON.stringify(info, null, 2)); -await browser.close(); diff --git a/test/manual/test-line-artifact.mjs b/test/manual/test-line-artifact.mjs deleted file mode 100644 index 43648a0c..00000000 --- a/test/manual/test-line-artifact.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true, deviceScaleFactor: 2 }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); -await page.locator('.session-tab').first().click(); -await page.waitForTimeout(4000); - -await page.evaluate(() => { - const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); - settings.localEchoEnabled = true; - localStorage.setItem('codeman-settings', JSON.stringify(settings)); - if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState(); -}); -await page.waitForTimeout(500); - -// Force render and capture zoomed area of just the overlay -const result = await page.evaluate(() => { - const overlay = window.app?._localEchoOverlay; - if (!overlay || !overlay._overlay) return { error: 'no overlay' }; - - overlay._lastPromptPos = { row: 5, col: 0 }; - overlay._pendingText = 'h'; - overlay._lastRenderKey = ''; - overlay._render(); - - const container = overlay._overlay; - const lineDiv = container.querySelector('div'); - const charSpan = container.querySelector('div > span'); - - // Get precise rects - const lineDivRect = lineDiv?.getBoundingClientRect(); - const charSpanRect = charSpan?.getBoundingClientRect(); - const screenRect = document.querySelector('.xterm-screen')?.getBoundingClientRect(); - - // Check what's right below the line div - const belowY = (lineDivRect?.bottom || 0) + 1; - const belowX = lineDivRect?.left || 0; - const elBelow = document.elementFromPoint(belowX, belowY); - - return { - dpr: window.devicePixelRatio, - lineDiv: { - rect: lineDivRect, - bg: lineDiv?.style.backgroundColor, - height: lineDiv?.style.height, - }, - charSpan: { - rect: charSpanRect, - transform: charSpan?.style.transform, - // Check if span bottom exceeds lineDiv bottom - extendsBelow: charSpanRect && lineDivRect ? - (charSpanRect.bottom - lineDivRect.bottom).toFixed(2) : 'N/A', - }, - elBelow: elBelow ? { - tag: elBelow.tagName, - class: elBelow.className, - } : null, - // Check the exact background color of the canvas - canvasBg: document.querySelector('.xterm-screen')?.style.backgroundColor, - themeBg: window.app?.terminal?.options?.theme?.background, - overlayBg: lineDiv?.style.backgroundColor, - }; -}); -console.log(JSON.stringify(result, null, 2)); - -// Take tight screenshot around the first char area -const screenEl = page.locator('.xterm-screen').first(); -const screenBox = await screenEl.boundingBox(); -if (screenBox) { - // Crop to just the overlay area (row 5, first few columns) - const cellH = 19; - const promptRow = 5; - const cropY = screenBox.y + (promptRow - 1) * cellH; - const cropH = cellH * 3; - await page.screenshot({ - path: '/tmp/first-char-zoomed.png', - clip: { x: screenBox.x, y: cropY, width: 200, height: cropH }, - }); - console.log('Zoomed screenshot saved'); -} - -await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); }); -await browser.close(); -console.log('Done'); diff --git a/test/manual/test-overlay-screenshot.mjs b/test/manual/test-overlay-screenshot.mjs deleted file mode 100644 index 385888e5..00000000 --- a/test/manual/test-overlay-screenshot.mjs +++ /dev/null @@ -1,116 +0,0 @@ -import { chromium } from 'playwright'; - -const SESSION_ID = '9270f4e4-84f7-414c-b901-837f12c30e59'; -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -page.on('console', msg => { - if (msg.type() === 'error') console.log('CONSOLE ERROR:', msg.text()); -}); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(4000); - -// Click on test session tab -const tab = page.locator(`.session-tab[data-id="${SESSION_ID}"]`); -if (await tab.isVisible({ timeout: 3000 }).catch(() => false)) { - await tab.click(); - await page.waitForTimeout(2000); -} else { - console.log('Tab not found, using whatever is active'); -} - -await page.waitForTimeout(2000); - -// Ensure local echo is OFF -const localEchoToggle = page.locator('#localEchoToggle'); -const isChecked = await localEchoToggle.isChecked().catch(() => false); -console.log('Local echo initially:', isChecked); - -if (isChecked) { - await localEchoToggle.click(); - await page.waitForTimeout(500); -} - -// Type with echo OFF -await page.locator('.xterm-helper-textarea').first().focus(); -await page.waitForTimeout(300); -await page.keyboard.type('hello test', { delay: 80 }); -await page.waitForTimeout(1000); - -const termScreen = page.locator('.xterm-screen').first(); -await termScreen.screenshot({ path: '/tmp/echo-off-term.png' }); -console.log('Saved /tmp/echo-off-term.png'); - -// Clear -for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace'); -await page.waitForTimeout(500); - -// Enable local echo -await localEchoToggle.click(); -await page.waitForTimeout(500); -console.log('Local echo now:', await localEchoToggle.isChecked().catch(() => false)); - -// Type with echo ON -await page.locator('.xterm-helper-textarea').first().focus(); -await page.waitForTimeout(300); -await page.keyboard.type('hello test', { delay: 80 }); -await page.waitForTimeout(1500); - -await termScreen.screenshot({ path: '/tmp/echo-on-term.png' }); -console.log('Saved /tmp/echo-on-term.png'); -await page.screenshot({ path: '/tmp/echo-on-full.png' }); - -// Inspect overlay DOM -const overlayInfo = await page.evaluate(() => { - const overlay = document.querySelector('.zl-overlay'); - if (!overlay) return { found: false }; - - const firstDiv = overlay.querySelector('div'); - const spans = overlay.querySelectorAll('span'); - const firstCharSpan = Array.from(spans).find(s => s.textContent && s.textContent.trim()); - - return { - found: true, - display: overlay.style.display, - top: overlay.style.top, - containerOverflow: overlay.style.overflow, - containerHeight: overlay.style.height, - firstDiv: firstDiv ? { - height: firstDiv.style.height, - top: firstDiv.style.top, - overflow: firstDiv.style.overflow, - backgroundColor: firstDiv.style.backgroundColor, - } : null, - firstCharSpan: firstCharSpan ? { - text: firstCharSpan.textContent, - transform: firstCharSpan.style.transform, - top: firstCharSpan.style.top, - height: firstCharSpan.style.height, - lineHeight: firstCharSpan.style.lineHeight, - width: firstCharSpan.style.width, - } : null, - spanCount: spans.length, - }; -}); -console.log('\nOverlay info:', JSON.stringify(overlayInfo, null, 2)); - -// Cell dimensions -const cellInfo = await page.evaluate(() => { - if (window.app?.sessions) { - for (const [, s] of Object.entries(window.app.sessions)) { - const dims = s.terminal?._core?._renderService?.dimensions; - if (dims) return { css: dims.css?.cell, device_char: dims.device?.char }; - } - } - return { error: 'not found' }; -}); -console.log('Cell dims:', JSON.stringify(cellInfo, null, 2)); - -// Cleanup -await page.evaluate(async (id) => { - await fetch('/api/sessions/' + id, { method: 'DELETE' }); -}, SESSION_ID); - -await browser.close(); -console.log('\nDone'); diff --git a/test/manual/test-overlay2.mjs b/test/manual/test-overlay2.mjs deleted file mode 100644 index 13a9e0e6..00000000 --- a/test/manual/test-overlay2.mjs +++ /dev/null @@ -1,114 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -page.on('console', msg => console.log('CONSOLE:', msg.type(), msg.text())); -page.on('pageerror', err => console.log('PAGE ERROR:', err.message)); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(4000); - -// Create a fresh session -const resp = await page.evaluate(async () => { - const r = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'ov-test', mode: 'claude' }) - }); - return r.json(); -}); -const sid = resp.session?.id; -console.log('Session:', sid?.slice(0, 8)); -await page.waitForTimeout(3000); - -// Click on it -await page.locator(`.session-tab[data-id="${sid}"]`).click(); -await page.waitForTimeout(3000); - -// Check for the local echo toggle -const toggle = page.locator('#localEchoToggle'); -const toggleVisible = await toggle.isVisible().catch(() => false); -console.log('Toggle visible:', toggleVisible); - -if (!toggleVisible) { - // Maybe need to open settings or it's somewhere else - console.log('Looking for local echo toggle...'); - const all = await page.locator('input[type="checkbox"]').all(); - for (const cb of all) { - const id = await cb.getAttribute('id'); - const label = await cb.evaluate(el => el.parentElement?.textContent?.trim().slice(0, 30)); - console.log(' checkbox:', id, label); - } -} - -// Get cell dimensions first -const cellInfo = await page.evaluate(() => { - if (window.app?.sessions) { - for (const [id, s] of Object.entries(window.app.sessions)) { - const dims = s.terminal?._core?._renderService?.dimensions; - if (dims) return { - id: id.slice(0, 8), - cssCell: dims.css?.cell, - deviceChar: dims.device?.char, - actualFull: dims, - }; - } - } - return { error: 'no terminal' }; -}); -console.log('Cell info:', JSON.stringify(cellInfo, null, 2)); - -// Focus terminal and type with echo OFF -const textarea = page.locator('.xterm-helper-textarea').first(); -await textarea.focus(); -await page.waitForTimeout(500); -await page.keyboard.type('hello test', { delay: 80 }); -await page.waitForTimeout(1500); - -await page.screenshot({ path: '/tmp/echo-off-full.png' }); -console.log('Saved echo-off-full.png'); - -// Clear -for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace'); -await page.waitForTimeout(500); - -// Enable local echo -if (toggleVisible) { - await toggle.click(); - await page.waitForTimeout(500); - console.log('Toggled local echo ON'); -} - -// Type again -await textarea.focus(); -await page.waitForTimeout(300); -await page.keyboard.type('hello test', { delay: 80 }); -await page.waitForTimeout(1500); - -await page.screenshot({ path: '/tmp/echo-on-full.png' }); -console.log('Saved echo-on-full.png'); - -// Get overlay info -const oi = await page.evaluate(() => { - const o = document.querySelector('.zl-overlay'); - if (!o) return { found: false, html: 'none' }; - return { - found: true, - display: o.style.display, - top: o.style.top, - childCount: o.children.length, - innerHTML: o.innerHTML.slice(0, 500), - }; -}); -console.log('Overlay:', JSON.stringify(oi, null, 2)); - -// Cleanup -if (sid) { - await page.evaluate(async (id) => { - await fetch('/api/sessions/' + id, { method: 'DELETE' }); - }, sid); -} - -await browser.close(); -console.log('Done'); diff --git a/test/manual/test-pixel.mjs b/test/manual/test-pixel.mjs deleted file mode 100644 index b9232fc0..00000000 --- a/test/manual/test-pixel.mjs +++ /dev/null @@ -1,109 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); - -// Click first tab -await page.locator('.session-tab').first().click(); -await page.waitForTimeout(4000); - -// Get terminal and enable local echo via JS -const result = await page.evaluate(() => { - const term = window.app.terminal; - if (!term) return { error: 'no terminal' }; - - const dims = term._core._renderService.dimensions; - - // Toggle local echo on via settings - const checkbox = document.getElementById('appSettingsLocalEcho'); - - return { - cellW: dims.css.cell.width, - cellH: dims.css.cell.height, - charTop: dims.device?.char?.top ?? 0, - charHeight: dims.device?.char?.height ?? 0, - dpr: window.devicePixelRatio, - fontSize: term.options.fontSize, - fontFamily: term.options.fontFamily, - localEchoCheckbox: !!checkbox, - localEchoChecked: checkbox?.checked, - }; -}); -console.log('Terminal info:', JSON.stringify(result, null, 2)); - -// Enable local echo via the app settings checkbox -await page.evaluate(() => { - const cb = document.getElementById('appSettingsLocalEcho'); - if (cb && !cb.checked) { - cb.click(); - cb.dispatchEvent(new Event('change', { bubbles: true })); - } -}); -await page.waitForTimeout(500); - -// Focus terminal and type -await page.locator('.xterm-helper-textarea').first().focus(); -await page.waitForTimeout(300); -await page.keyboard.type('hello', { delay: 100 }); -await page.waitForTimeout(1500); - -// Take screenshots and analyze pixel positions -const pixelInfo = await page.evaluate(() => { - const overlay = document.querySelector('.zl-overlay'); - if (!overlay) return { error: 'no overlay', overlayDisplay: overlay?.style?.display }; - - const spans = overlay.querySelectorAll('span'); - const firstCharSpan = Array.from(spans).find(s => s.textContent === 'h'); - - if (!firstCharSpan) { - return { error: 'no h span', spanCount: spans.length, innerHTML: overlay.innerHTML.slice(0, 200) }; - } - - const spanRect = firstCharSpan.getBoundingClientRect(); - const containerRect = overlay.getBoundingClientRect(); - const lineDiv = firstCharSpan.parentElement; - const lineDivRect = lineDiv.getBoundingClientRect(); - - // Get the canvas position for comparison - const canvasEl = document.querySelector('.xterm-screen canvas'); - const canvasRect = canvasEl?.getBoundingClientRect(); - - return { - span: { - top: spanRect.top, - bottom: spanRect.bottom, - height: spanRect.height, - relativeTop: spanRect.top - lineDivRect.top, - }, - lineDiv: { - top: lineDivRect.top, - bottom: lineDivRect.bottom, - height: lineDivRect.height, - }, - container: { - top: containerRect.top, - height: containerRect.height, - }, - canvas: canvasRect ? { - top: canvasRect.top, - } : null, - spanStyle: { - transform: firstCharSpan.style.transform, - top: firstCharSpan.style.top, - height: firstCharSpan.style.height, - lineHeight: firstCharSpan.style.lineHeight, - }, - }; -}); -console.log('Pixel info:', JSON.stringify(pixelInfo, null, 2)); - -// Screenshot the terminal area -const termScreen = page.locator('.xterm-screen').first(); -await termScreen.screenshot({ path: '/tmp/overlay-test-zoomed.png' }); -console.log('Screenshot saved to /tmp/overlay-test-zoomed.png'); - -await browser.close(); -console.log('Done'); diff --git a/test/manual/test-sc.mjs b/test/manual/test-sc.mjs deleted file mode 100644 index b6a01fa5..00000000 --- a/test/manual/test-sc.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); -await page.locator('.session-tab').first().click(); -await page.waitForTimeout(4000); - -// Enable local echo -await page.evaluate(() => { - const s = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); - s.localEchoEnabled = true; - localStorage.setItem('codeman-settings', JSON.stringify(s)); - window.app?.updateLocalEchoState?.(); -}); -await page.waitForTimeout(500); - -// Force render overlay with single char at a realistic prompt position -await page.evaluate(() => { - const ov = window.app?._localEchoOverlay; - if (!ov) return; - // Find actual bottom row for a realistic position - const buf = ov._terminal?.buffer?.active; - const row = buf ? Math.max(0, buf.baseY + ov._terminal.rows - 2) - buf.viewportY : 5; - ov._lastPromptPos = { row, col: 0 }; - ov._pendingText = 'hello test'; - ov._lastRenderKey = ''; - ov._render(); -}); -await page.waitForTimeout(500); - -// Take full-page and zoomed screenshots -await page.screenshot({ path: '/tmp/sc-full.png' }); - -// Zoom into overlay area — find where the overlay actually is -const overlayRect = await page.evaluate(() => { - const ov = window.app?._localEchoOverlay?._overlay; - if (!ov) return null; - const r = ov.getBoundingClientRect(); - const lineDiv = ov.querySelector('div'); - const lr = lineDiv?.getBoundingClientRect(); - return { - container: { x: r.x, y: r.y, w: r.width, h: r.height }, - lineDiv: lr ? { x: lr.x, y: lr.y, w: lr.width, h: lr.height } : null, - }; -}); -console.log('Overlay rect:', JSON.stringify(overlayRect)); - -if (overlayRect?.lineDiv) { - const ld = overlayRect.lineDiv; - // Capture area around the overlay: 1 row above, the overlay, 1 row below - const margin = 20; // ~1 cell height - await page.screenshot({ - path: '/tmp/sc-overlay-zoomed.png', - clip: { - x: Math.max(0, ld.x - 5), - y: Math.max(0, ld.y - margin), - width: Math.min(400, ld.w), - height: ld.h + margin * 2 + 5, - }, - }); - console.log('Zoomed screenshot saved'); -} - -await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); }); -await browser.close(); -console.log('Done'); diff --git a/test/manual/test-verify-fix.mjs b/test/manual/test-verify-fix.mjs deleted file mode 100644 index a147d2c0..00000000 --- a/test/manual/test-verify-fix.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); - -await page.locator('.session-tab').first().click(); -await page.waitForTimeout(4000); - -// Enable local echo and update state -await page.evaluate(() => { - const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); - settings.localEchoEnabled = true; - localStorage.setItem('codeman-settings', JSON.stringify(settings)); - if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState(); -}); -await page.waitForTimeout(500); - -// Force render with fake prompt position and inspect -const result = await page.evaluate(() => { - const overlay = window.app?._localEchoOverlay; - if (!overlay || !overlay._overlay) return { error: 'no overlay' }; - - const term = overlay._terminal; - const dims = term._core._renderService.dimensions; - const cellH = dims.css.cell.height; - const charTop = dims.device?.char?.top ?? 0; - const charHeight = dims.device?.char?.height ?? cellH; - - // Calculate the correction - const cssCenter = (cellH - charHeight) / 2; - const correction = charTop - cssCenter; - - // Force render - overlay._lastPromptPos = { row: 5, col: 0 }; - overlay._pendingText = 'hello test'; - overlay._lastRenderKey = ''; - overlay._render(); - - const spans = overlay._overlay.querySelectorAll('span'); - const firstChar = Array.from(spans).find(s => s.textContent === 'h'); - - return { - cellH, charTop, charHeight, - cssCenter, - correction: correction.toFixed(2), - firstChar: firstChar ? { - top: firstChar.style.top, - height: firstChar.style.height, - lineHeight: firstChar.style.lineHeight, - transform: firstChar.style.transform, - } : null, - }; -}); -console.log('Fix result:', JSON.stringify(result, null, 2)); - -// Take screenshot -await page.locator('.xterm-screen').first().screenshot({ path: '/tmp/fix-verify-term.png' }); -console.log('Screenshot saved'); - -// Clean up -await page.evaluate(() => { - window.app?._localEchoOverlay?.clear(); -}); - -await browser.close(); -console.log('Done'); diff --git a/test/manual/test-verify-no-overhang.mjs b/test/manual/test-verify-no-overhang.mjs deleted file mode 100644 index 80589ebe..00000000 --- a/test/manual/test-verify-no-overhang.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - -await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' }); -await page.waitForTimeout(6000); -await page.locator('.session-tab').first().click(); -await page.waitForTimeout(4000); - -await page.evaluate(() => { - const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}'); - settings.localEchoEnabled = true; - localStorage.setItem('codeman-settings', JSON.stringify(settings)); - if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState(); -}); - -const result = await page.evaluate(() => { - const overlay = window.app?._localEchoOverlay; - if (!overlay) return { error: 'no overlay' }; - - overlay._lastPromptPos = { row: 5, col: 0 }; - overlay._pendingText = 'hello test'; - overlay._lastRenderKey = ''; - overlay._render(); - - const container = overlay._overlay; - const lineDiv = container.querySelector('div'); - const charSpan = container.querySelector('div > span'); - - const lineDivRect = lineDiv?.getBoundingClientRect(); - const charSpanRect = charSpan?.getBoundingClientRect(); - - return { - lineDiv: { bottom: lineDivRect?.bottom, height: lineDivRect?.height }, - charSpan: { - bottom: charSpanRect?.bottom, - height: charSpanRect?.height, - transform: charSpan?.style.transform, - top: charSpan?.style.top, - lineHeight: charSpan?.style.lineHeight, - }, - extendsBelow: charSpanRect && lineDivRect ? - (charSpanRect.bottom - lineDivRect.bottom).toFixed(4) : 'N/A', - }; -}); -console.log(JSON.stringify(result, null, 2)); - -// Screenshot -const screenEl = page.locator('.xterm-screen').first(); -const screenBox = await screenEl.boundingBox(); -if (screenBox) { - await page.screenshot({ - path: '/tmp/no-overhang-zoomed.png', - clip: { x: screenBox.x, y: screenBox.y + 4 * 19, width: 200, height: 57 }, - }); -} - -await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); }); -await browser.close(); -console.log('Done'); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 12189665..cc03e1e5 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -1,7 +1,5 @@ /** * Shared MockSession for tests that need terminal simulation. - * - * Copied from test/respawn-test-utils.ts (the canonical, most complete version). * Used by respawn, route, and subagent tests. */ import { EventEmitter } from 'node:events'; diff --git a/test/mocks/test-helpers.ts b/test/mocks/test-helpers.ts index d025d24b..cc64a787 100644 --- a/test/mocks/test-helpers.ts +++ b/test/mocks/test-helpers.ts @@ -1,18 +1,17 @@ /** * Reusable async test helpers. - * Extracted from respawn-test-utils.ts. */ /** Wait for an EventEmitter to emit a specific event, with timeout */ export function waitForEvent( emitter: { once: (event: string, listener: (...args: unknown[]) => void) => void }, event: string, - timeoutMs = 5000, + timeoutMs = 5000 ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error(`Timed out waiting for event "${event}" after ${timeoutMs}ms`)), - timeoutMs, + timeoutMs ); emitter.once(event, (...args: unknown[]) => { clearTimeout(timer); diff --git a/test/respawn-test-utils.ts b/test/respawn-test-utils.ts deleted file mode 100644 index 4dac36b2..00000000 --- a/test/respawn-test-utils.ts +++ /dev/null @@ -1,727 +0,0 @@ -/** - * @fileoverview Test Utilities for RespawnController Tests - * - * Provides respawn-specific mocks, helpers, and utilities for testing the - * RespawnController and its related AI checker components. - * - * General-purpose mocks (MockSession, terminalOutputs, etc.) have been moved - * to test/mocks/ — re-exported here for backward compatibility. - * - * @module test/respawn-test-utils - */ - -import { EventEmitter } from 'node:events'; -import { vi } from 'vitest'; -import type { Session } from '../src/session.js'; -import type { RespawnConfig, RespawnState } from '../src/respawn-controller.js'; -import type { - AiCheckResult, - AiCheckState, - AiCheckStatus, - AiCheckVerdict, - AiIdleCheckConfig, -} from '../src/ai-idle-checker.js'; -import type { - AiPlanCheckResult, - AiPlanCheckState, - AiPlanCheckStatus, - AiPlanCheckVerdict, - AiPlanCheckConfig, -} from '../src/ai-plan-checker.js'; - -// Re-export shared mocks for backward compatibility -export { MockSession, createMockSession, terminalOutputs } from './mocks/mock-session.js'; -export { waitForEvent, createDeferred } from './mocks/test-helpers.js'; - -// ========== Time Manipulation Utilities ========== - -/** - * Time controller for testing timeout-based behavior. - * Uses vitest's fake timers for deterministic time control. - */ -export interface TimeController { - /** Advance time by the specified milliseconds */ - advanceBy(ms: number): Promise; - /** Advance to the next timer */ - advanceToNextTimer(): Promise; - /** Run all pending timers */ - runAllTimers(): Promise; - /** Get the current fake time */ - now(): number; - /** Reset to real timers */ - useRealTimers(): void; -} - -/** - * Create a time controller for testing timeout-based behavior. - * Call this in beforeEach and cleanup with controller.useRealTimers() in afterEach. - */ -export function createTimeController(): TimeController { - vi.useFakeTimers(); - - return { - async advanceBy(ms: number): Promise { - await vi.advanceTimersByTimeAsync(ms); - }, - async advanceToNextTimer(): Promise { - await vi.runOnlyPendingTimersAsync(); - }, - async runAllTimers(): Promise { - await vi.runAllTimersAsync(); - }, - now(): number { - return Date.now(); - }, - useRealTimers(): void { - vi.useRealTimers(); - }, - }; -} - -// ========== MockAiIdleChecker ========== - -/** - * Configuration for MockAiIdleChecker behavior. - */ -export interface MockAiIdleCheckerOptions { - /** Default verdict to return */ - defaultVerdict?: AiCheckVerdict; - /** Default reasoning to include */ - defaultReasoning?: string; - /** Simulated check duration in ms */ - checkDurationMs?: number; - /** Whether to start in disabled state */ - startDisabled?: boolean; - /** Reason if starting disabled */ - disabledReason?: string; -} - -/** - * Mock AI idle checker for testing without spawning real Claude CLI. - * Allows configuring verdicts and simulating various states. - */ -export class MockAiIdleChecker extends EventEmitter { - private config: AiIdleCheckConfig; - private _status: AiCheckStatus = 'ready'; - private _lastVerdict: AiCheckVerdict | null = null; - private _lastReasoning: string | null = null; - private _lastCheckDurationMs: number | null = null; - private _cooldownEndsAt: number | null = null; - private _consecutiveErrors: number = 0; - private _totalChecks: number = 0; - private _disabledReason: string | null = null; - - // Queued results for sequential calls - private queuedResults: AiCheckResult[] = []; - private defaultOptions: MockAiIdleCheckerOptions; - private cooldownTimer: NodeJS.Timeout | null = null; - - constructor( - public sessionId: string, - config: Partial = {}, - options: MockAiIdleCheckerOptions = {}, - ) { - super(); - this.config = { - enabled: true, - model: 'claude-opus-4-5-20251101', - maxContextChars: 16000, - checkTimeoutMs: 90000, - cooldownMs: 180000, - errorCooldownMs: 60000, - maxConsecutiveErrors: 3, - ...config, - }; - this.defaultOptions = { - defaultVerdict: 'IDLE', - defaultReasoning: 'Mock verdict', - checkDurationMs: 100, - startDisabled: false, - ...options, - }; - - if (this.defaultOptions.startDisabled) { - this._status = 'disabled'; - this._disabledReason = this.defaultOptions.disabledReason || 'Disabled for testing'; - } - } - - get status(): AiCheckStatus { - return this._status; - } - - getState(): AiCheckState { - return { - status: this._status, - lastVerdict: this._lastVerdict, - lastReasoning: this._lastReasoning, - lastCheckDurationMs: this._lastCheckDurationMs, - cooldownEndsAt: this._cooldownEndsAt, - consecutiveErrors: this._consecutiveErrors, - totalChecks: this._totalChecks, - disabledReason: this._disabledReason, - }; - } - - isOnCooldown(): boolean { - return this._cooldownEndsAt !== null && Date.now() < this._cooldownEndsAt; - } - - getCooldownRemainingMs(): number { - if (this._cooldownEndsAt === null) return 0; - return Math.max(0, this._cooldownEndsAt - Date.now()); - } - - queueResult(result: AiCheckResult): void { - this.queuedResults.push(result); - } - - queueResults(...results: AiCheckResult[]): void { - this.queuedResults.push(...results); - } - - setNextIdle(reasoning: string = 'Mock: IDLE'): void { - this.queueResult({ - verdict: 'IDLE', - reasoning, - durationMs: this.defaultOptions.checkDurationMs || 100, - }); - } - - setNextWorking(reasoning: string = 'Mock: WORKING'): void { - this.queueResult({ - verdict: 'WORKING', - reasoning, - durationMs: this.defaultOptions.checkDurationMs || 100, - }); - } - - setNextError(reasoning: string = 'Mock: ERROR'): void { - this.queueResult({ - verdict: 'ERROR', - reasoning, - durationMs: this.defaultOptions.checkDurationMs || 100, - }); - } - - async check(_terminalBuffer: string): Promise { - if (this._status === 'disabled') { - return { verdict: 'ERROR', reasoning: `Disabled: ${this._disabledReason}`, durationMs: 0 }; - } - - if (this.isOnCooldown()) { - return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 }; - } - - if (this._status === 'checking') { - return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 }; - } - - this._status = 'checking'; - this._totalChecks++; - this.emit('checkStarted'); - - // Simulate async check - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Get result from queue or use default - const result = this.queuedResults.shift() || { - verdict: this.defaultOptions.defaultVerdict!, - reasoning: this.defaultOptions.defaultReasoning!, - durationMs: this.defaultOptions.checkDurationMs!, - }; - - this._lastVerdict = result.verdict; - this._lastReasoning = result.reasoning; - this._lastCheckDurationMs = result.durationMs; - - if (result.verdict === 'IDLE') { - this._consecutiveErrors = 0; - this._status = 'ready'; - } else if (result.verdict === 'WORKING') { - this._consecutiveErrors = 0; - this.startCooldown(this.config.cooldownMs); - } else { - this._consecutiveErrors++; - if (this._consecutiveErrors >= this.config.maxConsecutiveErrors) { - this._status = 'disabled'; - this._disabledReason = `${this.config.maxConsecutiveErrors} consecutive errors`; - this.emit('disabled', this._disabledReason); - } else { - this.startCooldown(this.config.errorCooldownMs); - } - } - - this.emit('checkCompleted', result); - return result; - } - - cancel(): void { - if (this._status === 'checking') { - this._status = 'ready'; - this.emit('log', '[MockAiIdleChecker] Check cancelled'); - } - } - - reset(): void { - this.cancel(); - this.clearCooldown(); - this._lastVerdict = null; - this._lastReasoning = null; - this._lastCheckDurationMs = null; - this._consecutiveErrors = 0; - this.queuedResults = []; - this._status = this._disabledReason ? 'disabled' : 'ready'; - } - - updateConfig(config: Partial): void { - const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined), - ) as Partial; - this.config = { ...this.config, ...filteredConfig }; - if (config.enabled === false) { - this._disabledReason = 'Disabled by config'; - this._status = 'disabled'; - } else if (config.enabled === true && this._status === 'disabled') { - this._disabledReason = null; - this._status = 'ready'; - } - } - - getConfig(): AiIdleCheckConfig { - return { ...this.config }; - } - - // ========== Test Helpers ========== - - forceCooldown(durationMs: number): void { - this.startCooldown(durationMs); - } - - forceDisabled(reason: string = 'Forced disabled for testing'): void { - this._status = 'disabled'; - this._disabledReason = reason; - this.emit('disabled', reason); - } - - forceReady(): void { - this.clearCooldown(); - this._status = 'ready'; - this._disabledReason = null; - } - - private startCooldown(durationMs: number): void { - this.clearCooldown(); - this._cooldownEndsAt = Date.now() + durationMs; - this._status = 'cooldown'; - this.emit('cooldownStarted', this._cooldownEndsAt); - - this.cooldownTimer = setTimeout(() => { - this._cooldownEndsAt = null; - this._status = 'ready'; - this.emit('cooldownEnded'); - }, durationMs); - } - - private clearCooldown(): void { - if (this.cooldownTimer) { - clearTimeout(this.cooldownTimer); - this.cooldownTimer = null; - } - this._cooldownEndsAt = null; - if (this._status === 'cooldown') { - this._status = 'ready'; - } - } -} - -// ========== MockAiPlanChecker ========== - -/** - * Configuration for MockAiPlanChecker behavior. - */ -export interface MockAiPlanCheckerOptions { - /** Default verdict to return */ - defaultVerdict?: AiPlanCheckVerdict; - /** Default reasoning to include */ - defaultReasoning?: string; - /** Simulated check duration in ms */ - checkDurationMs?: number; - /** Whether to start in disabled state */ - startDisabled?: boolean; - /** Reason if starting disabled */ - disabledReason?: string; -} - -/** - * Mock AI plan checker for testing without spawning real Claude CLI. - * Allows configuring verdicts and simulating various states. - */ -export class MockAiPlanChecker extends EventEmitter { - private config: AiPlanCheckConfig; - private _status: AiPlanCheckStatus = 'ready'; - private _lastVerdict: AiPlanCheckVerdict | null = null; - private _lastReasoning: string | null = null; - private _lastCheckDurationMs: number | null = null; - private _cooldownEndsAt: number | null = null; - private _consecutiveErrors: number = 0; - private _totalChecks: number = 0; - private _disabledReason: string | null = null; - - // Queued results for sequential calls - private queuedResults: AiPlanCheckResult[] = []; - private defaultOptions: MockAiPlanCheckerOptions; - private cooldownTimer: NodeJS.Timeout | null = null; - - constructor( - public sessionId: string, - config: Partial = {}, - options: MockAiPlanCheckerOptions = {}, - ) { - super(); - this.config = { - enabled: true, - model: 'claude-opus-4-5-20251101', - maxContextChars: 8000, - checkTimeoutMs: 60000, - cooldownMs: 30000, - errorCooldownMs: 30000, - maxConsecutiveErrors: 3, - ...config, - }; - this.defaultOptions = { - defaultVerdict: 'PLAN_MODE', - defaultReasoning: 'Mock verdict', - checkDurationMs: 100, - startDisabled: false, - ...options, - }; - - if (this.defaultOptions.startDisabled) { - this._status = 'disabled'; - this._disabledReason = this.defaultOptions.disabledReason || 'Disabled for testing'; - } - } - - get status(): AiPlanCheckStatus { - return this._status; - } - - getState(): AiPlanCheckState { - return { - status: this._status, - lastVerdict: this._lastVerdict, - lastReasoning: this._lastReasoning, - lastCheckDurationMs: this._lastCheckDurationMs, - cooldownEndsAt: this._cooldownEndsAt, - consecutiveErrors: this._consecutiveErrors, - totalChecks: this._totalChecks, - disabledReason: this._disabledReason, - }; - } - - isOnCooldown(): boolean { - return this._cooldownEndsAt !== null && Date.now() < this._cooldownEndsAt; - } - - getCooldownRemainingMs(): number { - if (this._cooldownEndsAt === null) return 0; - return Math.max(0, this._cooldownEndsAt - Date.now()); - } - - queueResult(result: AiPlanCheckResult): void { - this.queuedResults.push(result); - } - - queueResults(...results: AiPlanCheckResult[]): void { - this.queuedResults.push(...results); - } - - setNextPlanMode(reasoning: string = 'Mock: PLAN_MODE'): void { - this.queueResult({ - verdict: 'PLAN_MODE', - reasoning, - durationMs: this.defaultOptions.checkDurationMs || 100, - }); - } - - setNextNotPlanMode(reasoning: string = 'Mock: NOT_PLAN_MODE'): void { - this.queueResult({ - verdict: 'NOT_PLAN_MODE', - reasoning, - durationMs: this.defaultOptions.checkDurationMs || 100, - }); - } - - setNextError(reasoning: string = 'Mock: ERROR'): void { - this.queueResult({ - verdict: 'ERROR', - reasoning, - durationMs: this.defaultOptions.checkDurationMs || 100, - }); - } - - async check(_terminalBuffer: string): Promise { - if (this._status === 'disabled') { - return { verdict: 'ERROR', reasoning: `Disabled: ${this._disabledReason}`, durationMs: 0 }; - } - - if (this.isOnCooldown()) { - return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 }; - } - - if (this._status === 'checking') { - return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 }; - } - - this._status = 'checking'; - this._totalChecks++; - this.emit('checkStarted'); - - // Simulate async check - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Get result from queue or use default - const result = this.queuedResults.shift() || { - verdict: this.defaultOptions.defaultVerdict!, - reasoning: this.defaultOptions.defaultReasoning!, - durationMs: this.defaultOptions.checkDurationMs!, - }; - - this._lastVerdict = result.verdict; - this._lastReasoning = result.reasoning; - this._lastCheckDurationMs = result.durationMs; - - if (result.verdict === 'PLAN_MODE') { - this._consecutiveErrors = 0; - this._status = 'ready'; - } else if (result.verdict === 'NOT_PLAN_MODE') { - this._consecutiveErrors = 0; - this.startCooldown(this.config.cooldownMs); - } else { - this._consecutiveErrors++; - if (this._consecutiveErrors >= this.config.maxConsecutiveErrors) { - this._status = 'disabled'; - this._disabledReason = `${this.config.maxConsecutiveErrors} consecutive errors`; - this.emit('disabled', this._disabledReason); - } else { - this.startCooldown(this.config.errorCooldownMs); - } - } - - this.emit('checkCompleted', result); - return result; - } - - cancel(): void { - if (this._status === 'checking') { - this._status = 'ready'; - this.emit('log', '[MockAiPlanChecker] Check cancelled'); - } - } - - reset(): void { - this.cancel(); - this.clearCooldown(); - this._lastVerdict = null; - this._lastReasoning = null; - this._lastCheckDurationMs = null; - this._consecutiveErrors = 0; - this.queuedResults = []; - this._status = this._disabledReason ? 'disabled' : 'ready'; - } - - updateConfig(config: Partial): void { - const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined), - ) as Partial; - this.config = { ...this.config, ...filteredConfig }; - if (config.enabled === false) { - this._disabledReason = 'Disabled by config'; - this._status = 'disabled'; - } else if (config.enabled === true && this._status === 'disabled') { - this._disabledReason = null; - this._status = 'ready'; - } - } - - getConfig(): AiPlanCheckConfig { - return { ...this.config }; - } - - // ========== Test Helpers ========== - - forceCooldown(durationMs: number): void { - this.startCooldown(durationMs); - } - - forceDisabled(reason: string = 'Forced disabled for testing'): void { - this._status = 'disabled'; - this._disabledReason = reason; - this.emit('disabled', reason); - } - - forceReady(): void { - this.clearCooldown(); - this._status = 'ready'; - this._disabledReason = null; - } - - private startCooldown(durationMs: number): void { - this.clearCooldown(); - this._cooldownEndsAt = Date.now() + durationMs; - this._status = 'cooldown'; - this.emit('cooldownStarted', this._cooldownEndsAt); - - this.cooldownTimer = setTimeout(() => { - this._cooldownEndsAt = null; - this._status = 'ready'; - this.emit('cooldownEnded'); - }, durationMs); - } - - private clearCooldown(): void { - if (this.cooldownTimer) { - clearTimeout(this.cooldownTimer); - this.cooldownTimer = null; - } - this._cooldownEndsAt = null; - if (this._status === 'cooldown') { - this._status = 'ready'; - } - } -} - -// ========== State Transition Helpers ========== - -/** - * State transition event for tracking. - */ -export interface StateTransition { - from: RespawnState; - to: RespawnState; - timestamp: number; -} - -/** - * Create a state tracker that records all state transitions. - * Attach to a RespawnController via controller.on('stateChanged', tracker.record). - */ -export function createStateTracker() { - const transitions: StateTransition[] = []; - - return { - record(to: RespawnState, from: RespawnState): void { - transitions.push({ from, to, timestamp: Date.now() }); - }, - getTransitions(): StateTransition[] { - return [...transitions]; - }, - getStates(): RespawnState[] { - return transitions.map((t) => t.to); - }, - hasVisited(state: RespawnState): boolean { - return transitions.some((t) => t.to === state); - }, - hasTransition(from: RespawnState, to: RespawnState): boolean { - return transitions.some((t) => t.from === from && t.to === to); - }, - getCurrentState(): RespawnState | undefined { - return transitions.length > 0 ? transitions[transitions.length - 1].to : undefined; - }, - clear(): void { - transitions.length = 0; - }, - }; -} - -/** - * Create an event recorder for tracking all events from a RespawnController. - */ -export function createEventRecorder() { - const events: Array<{ type: string; args: unknown[]; timestamp: number }> = []; - - return { - handler(type: string): (...args: unknown[]) => void { - return (...args: unknown[]) => { - events.push({ type, args, timestamp: Date.now() }); - }; - }, - getEvents(): Array<{ type: string; args: unknown[]; timestamp: number }> { - return [...events]; - }, - getEventsOfType(type: string): Array<{ type: string; args: unknown[]; timestamp: number }> { - return events.filter((e) => e.type === type); - }, - hasEvent(type: string): boolean { - return events.some((e) => e.type === type); - }, - countEvents(type: string): number { - return events.filter((e) => e.type === type).length; - }, - clear(): void { - events.length = 0; - }, - }; -} - -// ========== Factory Functions ========== - -/** - * Default fast test configuration. - * Uses short timeouts for faster test execution. - */ -export const FAST_TEST_CONFIG: Partial = { - idleTimeoutMs: 50, - interStepDelayMs: 20, - completionConfirmMs: 50, - noOutputTimeoutMs: 300, - autoAcceptDelayMs: 100, - aiIdleCheckEnabled: false, - aiPlanCheckEnabled: false, -}; - -/** - * Configuration with AI checks enabled but short timeouts. - */ -export const AI_ENABLED_TEST_CONFIG: Partial = { - ...FAST_TEST_CONFIG, - aiIdleCheckEnabled: true, - aiIdleCheckTimeoutMs: 500, - aiIdleCheckCooldownMs: 200, - aiPlanCheckEnabled: true, - aiPlanCheckTimeoutMs: 500, - aiPlanCheckCooldownMs: 200, -}; - -// ========== Test Assertion Helpers ========== - -/** - * Wait for a specific state to be reached. - * Useful for async state transition testing. - */ -export async function waitForState( - controller: { - state: RespawnState; - on: (event: string, handler: (state: RespawnState) => void) => void; - }, - targetState: RespawnState, - timeoutMs: number = 1000, -): Promise { - if (controller.state === targetState) return; - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`Timeout waiting for state ${targetState}, current: ${controller.state}`)); - }, timeoutMs); - - const handler = (state: RespawnState) => { - if (state === targetState) { - clearTimeout(timeout); - resolve(); - } - }; - - controller.on('stateChanged', handler); - }); -}