From c820acc96e21b30fbde97cbc05760bdd1107830a Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 21 Feb 2026 23:37:29 +0100 Subject: [PATCH] fix: make localEchoEnabled per-platform so desktop/mobile settings don't overwrite each other Strip localEchoEnabled from server PUT payload (stays in device-specific localStorage only). Add to displayKeys as safety net for stale server values. chore: bump version to 0.1588 --- CLAUDE.md | 2 +- package.json | 2 +- src/web/public/app.js | 199 +++++++-- test/local-echo-basic-test.mjs | 590 ++++++++++++++++++++++++++ test/local-echo-daily-test.mjs | 395 ++++++++++++++++++ test/local-echo-edge-test.mjs | 533 ++++++++++++++++++++++++ test/local-echo-mobile-test.mjs | 712 ++++++++++++++++++++++++++++++++ test/local-echo-scroll-test.mjs | 398 ++++++++++++++++++ 8 files changed, 2786 insertions(+), 45 deletions(-) create mode 100644 test/local-echo-basic-test.mjs create mode 100644 test/local-echo-daily-test.mjs create mode 100644 test/local-echo-edge-test.mjs create mode 100644 test/local-echo-mobile-test.mjs create mode 100644 test/local-echo-scroll-test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index f3080ebe..2209b7df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1587 (must match `package.json` for npm publish) +**Version**: 0.1588 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 1e3c356f..26212ee4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1587", + "version": "0.1588", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 9d1f1dbc..5d11329f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -884,37 +884,57 @@ class LocalEchoOverlay { this._fontSize = terminal.options.fontSize + 'px'; this._fontWeight = terminal.options.fontWeight || 'normal'; this._bg = terminal.options?.theme?.background || '#0d0d0d'; - this._color = ''; + this._color = terminal.options?.theme?.foreground || '#eeeeee'; this._letterSpacing = ''; const rows = terminal.element.querySelector('.xterm-rows'); if (rows) { const cs = getComputedStyle(rows); this._letterSpacing = cs.letterSpacing; - this._color = cs.color; + // Prefer computed color from rendered rows (matches actual rendering), + // fall back to theme foreground if the computed value is empty/default. + if (cs.color) this._color = cs.color; } screen.appendChild(this.overlay); this.pendingText = ''; this._flushedOffset = 0; // chars flushed to PTY during tab switch (echo may not have arrived yet) this._storageKey = 'claudeman_local_echo_pending'; - // Restore unsent input from previous session (survives reload/disconnect) - try { - const saved = localStorage.getItem(this._storageKey); - if (saved) { - this.pendingText = saved; - this._render(); - } - } catch {} + // Clear stale text from previous page load. Persisted text can't be + // backspaced (flushedOffset is lost on reload) and ghost-merges with + // new input, breaking editing. Tab-switch text is already flushed to + // PTY directly — localStorage persistence is not needed. + try { localStorage.removeItem(this._storageKey); } catch {} + // Hide overlay when user scrolls away from bottom (prompt not in viewport). + // _render() checks viewportY !== baseY, but only on render triggers — scrolling + // alone doesn't trigger a render, so the overlay stays visible over wrong content. + // Listen on .xterm-viewport DOM element (terminal.onScroll fires on buffer changes). + // Debounce the re-render on scroll-to-bottom to avoid loadCell errors when the + // buffer is mid-update during rapid scroll events. + this._scrollTimer = null; + this._scrollHandler = () => { + try { + const buf = this.terminal.buffer.active; + if (buf.viewportY !== buf.baseY) { + this.overlay.style.display = 'none'; + if (this._scrollTimer) { clearTimeout(this._scrollTimer); this._scrollTimer = null; } + } else if (this.pendingText) { + // Debounce re-render — buffer lines may still be settling + if (this._scrollTimer) clearTimeout(this._scrollTimer); + this._scrollTimer = setTimeout(() => { + this._scrollTimer = null; + this._lastRenderKey = ''; + this._render(); + }, 50); + } + } catch {} + }; + const viewport = terminal.element.querySelector('.xterm-viewport'); + if (viewport) viewport.addEventListener('scroll', this._scrollHandler, { passive: true }); + this._scrollViewport = viewport; } - _persist() { - try { - if (this.pendingText) { - localStorage.setItem(this._storageKey, this.pendingText); - } else { - localStorage.removeItem(this._storageKey); - } - } catch {} - } + // No-op: localStorage persistence removed — stale text from previous page + // loads can't be edited (flushedOffset lost) and ghost-merges with new input. + _persist() {} // Scan terminal buffer to find the visible ❯ prompt position (bottom-up) _findPrompt() { @@ -947,7 +967,14 @@ class LocalEchoOverlay { if (this.pendingText.length > 0) { this._render(); } else { - this.clear(); + // Hide overlay but preserve _flushedOffset — the flushed text + // is still in the PTY buffer and the offset is needed if the + // user types again. clear() would reset it to 0 and desync + // from the _flushedOffsets Map. + this._lastRenderKey = ''; + this._lastPromptPos = null; + this.overlay.innerHTML = ''; + this.overlay.style.display = 'none'; } } } @@ -984,13 +1011,18 @@ class LocalEchoOverlay { // Place each character at its exact grid position for (let i = 0; i < text.length; i++) { const span = document.createElement('span'); - span.style.cssText = `position:absolute;display:inline-block;text-align:center;pointer-events:none`; + // Match xterm.js WebGL canvas text rendering as closely as possible: + // - antialiased font smoothing (canvas uses grayscale, not LCD subpixel) + // - geometricPrecision for consistent glyph sizing + // - no ligatures (canvas renders each glyph independently) + span.style.cssText = `position:absolute;display:inline-block;text-align:center;pointer-events:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:geometricPrecision;font-feature-settings:'liga' 0,'calt' 0`; span.style.left = (i * cellW) + 'px'; span.style.width = cellW + 'px'; span.style.fontFamily = this._fontFamily; span.style.fontSize = this._fontSize; span.style.fontWeight = this._fontWeight; span.style.color = this._color; + if (this._letterSpacing) span.style.letterSpacing = this._letterSpacing; span.textContent = text[i]; el.appendChild(span); } @@ -1003,11 +1035,27 @@ class LocalEchoOverlay { return; } try { + // Hide overlay when scrolled up — the current prompt is at the bottom + // of the buffer, not in the visible viewport. Rendering at a stale + // prompt position would overlap unrelated content. + const buf = this.terminal.buffer.active; + if (buf.viewportY !== buf.baseY) { + this.overlay.style.display = 'none'; + return; + } + // Re-scan for prompt on EVERY render — Ink can move it between redraws. // Cache last known position as fallback during Ink redraw flicker. const prompt = this._findPrompt(); if (prompt) { - this._lastPromptPos = prompt; + // When flushed text exists, lock the column to prevent jitter from + // Ink redraws that temporarily shift the ❯ prompt. Only allow the + // ROW to change (terminal output may scroll the prompt down). + if (this._lastPromptPos && (this._flushedOffset || 0) > 0) { + this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col }; + } else { + this._lastPromptPos = prompt; + } } else if (!this._lastPromptPos) { this.overlay.style.display = 'none'; return; } @@ -1017,13 +1065,30 @@ class LocalEchoOverlay { const cellW = dims.css.cell.width; const cellH = dims.css.cell.height; const totalCols = this.terminal.cols; - // Position right after "❯ " (prompt col + 2), plus any chars flushed - // to PTY during a tab switch that may not be echoed yet. - const startCol = Math.min(activePrompt.col + 2 + (this._flushedOffset || 0), totalCols - 1); + + // When flushed text exists alongside pending text, both render on the + // same prompt line but via different engines (canvas vs DOM), creating a + // visible font mismatch. Fix: read the flushed portion from the terminal + // buffer and re-render the ENTIRE line as DOM, covering the canvas text. + let displayText = this.pendingText; + const flushed = this._flushedOffset || 0; + const textStart = activePrompt.col + 2; // right after "❯ " + if (flushed > 0) { + // Read flushed chars from the terminal buffer (PTY already echoed them) + const promptAbsRow = buf.viewportY + activePrompt.row; + const line = buf.getLine(promptAbsRow); + if (line) { + const lineText = line.translateToString(true); + const flushedChars = lineText.slice(textStart, textStart + flushed); + displayText = flushedChars + this.pendingText; + } + } + + const startCol = textStart; const firstLineCols = Math.max(1, totalCols - startCol); // Skip redundant re-renders (e.g. from flushPendingWrites at 60fps) - const renderKey = `${this.pendingText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}`; + const renderKey = `${displayText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${flushed}`; if (renderKey === this._lastRenderKey && this.overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -1031,7 +1096,7 @@ class LocalEchoOverlay { // Line 0: starts after prompt, fits (totalCols - startCol) chars // Line 1+: starts at col 0, fits totalCols chars each const lines = []; - let remaining = this.pendingText; + let remaining = displayText; lines.push(remaining.slice(0, firstLineCols)); remaining = remaining.slice(firstLineCols); while (remaining.length > 0) { @@ -1078,6 +1143,26 @@ class LocalEchoOverlay { } } + // Refresh cached font properties from terminal options. + // Call after font size changes, theme changes, etc. + refreshFont() { + this._fontFamily = this.terminal.options.fontFamily; + this._fontSize = this.terminal.options.fontSize + 'px'; + this._fontWeight = this.terminal.options.fontWeight || 'normal'; + this._bg = this.terminal.options?.theme?.background || '#0d0d0d'; + const fg = this.terminal.options?.theme?.foreground || '#eeeeee'; + this._color = fg; + const rows = this.terminal.element?.querySelector('.xterm-rows'); + if (rows) { + const cs = getComputedStyle(rows); + this._letterSpacing = cs.letterSpacing; + if (cs.color) this._color = cs.color; + } + // Force full re-render with updated font (clear render cache) + this._lastRenderKey = ''; + if (this.pendingText) this._render(); + } + // Re-render overlay at current prompt position without clearing pending text. // Used after terminal resets/redraws that move the prompt. rerender() { @@ -1088,6 +1173,10 @@ class LocalEchoOverlay { dispose() { this.clear(); + if (this._scrollTimer) { clearTimeout(this._scrollTimer); this._scrollTimer = null; } + if (this._scrollViewport && this._scrollHandler) { + this._scrollViewport.removeEventListener('scroll', this._scrollHandler); + } this.overlay.remove(); } } @@ -1636,7 +1725,7 @@ class ClaudemanApp { this.teammateTerminals = new Map(); // Map this.terminalBufferCache = new Map(); // Map — client-side cache for instant tab re-visits (max 20) - this.localEchoTextCache = new Map(); // Map — preserve local echo input across tab switches + this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel @@ -2043,6 +2132,11 @@ class ClaudemanApp { } // Update subagent connection lines when viewport resizes this.updateConnectionLines(); + // Re-render local echo overlay at new cell dimensions/positions + if (this._localEchoOverlay?.hasPending) { + this._localEchoOverlay._lastRenderKey = ''; // force re-render (cols changed) + this._localEchoOverlay.rerender(); + } }, 100); // Throttle to 100ms }; @@ -2089,8 +2183,22 @@ class ClaudemanApp { // (or a control char) is pressed — avoids out-of-order char delivery. if (this._localEchoEnabled) { if (data === '\x7f') { - // Backspace: remove last char from overlay only (nothing sent to PTY yet) - this._localEchoOverlay?.removeChar(); + if (this._localEchoOverlay?.pendingText) { + // Backspace with pending overlay text: remove from overlay only + this._localEchoOverlay.removeChar(); + } else if (this._localEchoOverlay?._flushedOffset > 0) { + // No pending text but flushed text in PTY — send backspace to PTY + // so the user can delete chars that were auto-flushed during tab switch. + this._localEchoOverlay._flushedOffset--; + if (this._flushedOffsets?.has(this.activeSessionId)) { + const prev = this._flushedOffsets.get(this.activeSessionId); + if (prev <= 1) this._flushedOffsets.delete(this.activeSessionId); + else this._flushedOffsets.set(this.activeSessionId, prev - 1); + } + this._pendingInput += data; + flushInput(); + } + // else: nothing to delete — swallow the backspace return; } if (/^[\r\n]+$/.test(data)) { @@ -2123,6 +2231,9 @@ class ClaudemanApp { // Control chars (Ctrl+C, escape sequences): send buffered text + control char immediately const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); + // Clear flushed offset — control chars (Ctrl+C, Escape, arrows) change + // cursor position or abort readline, making flushed text tracking invalid. + this._flushedOffsets?.delete(this.activeSessionId); if (text) { this._pendingInput += text; } @@ -4024,8 +4135,6 @@ class ClaudemanApp { // Guard: skip if a newer handleInit has already started (race between loadState + SSE init). if (gen !== this._initGeneration) return; const previousActiveId = this.activeSessionId; - // Save local echo pending text before selectSession clears it - const savedEchoText = this._localEchoOverlay?.pendingText || ''; this.activeSessionId = null; if (this.sessionOrder.length > 0) { // Priority: current active > localStorage > first session @@ -4039,14 +4148,6 @@ class ClaudemanApp { this.selectSession(this.sessionOrder[0]); } } - // Restore local echo text that was cleared by selectSession during SSE reconnect. - // selectSession loads the terminal buffer, so we schedule rerender after it settles. - if (savedEchoText && this._localEchoOverlay) { - this._localEchoOverlay.pendingText = savedEchoText; - this._localEchoOverlay._persist(); - // Delay rerender until buffer load completes and prompt is visible - setTimeout(() => this._localEchoOverlay?.rerender(), 500); - } } async loadState() { @@ -4652,6 +4753,14 @@ class ClaudemanApp { this.renderSessionTabs(); this._updateLocalEchoState(); + // Restore flushed offset IMMEDIATELY so backspace/typing work during + // the async buffer load. Without this, the offset is 0 during the + // fetch() gap: backspace is swallowed, and typing a space covers the + // canvas text with an opaque overlay showing only the new char. + if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) { + this._localEchoOverlay._flushedOffset = this._flushedOffsets.get(sessionId); + } + // Glow the newly-active tab const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`); if (activeTab) { @@ -4811,7 +4920,7 @@ class ClaudemanApp { } this.terminalBuffers.delete(sessionId); this.terminalBufferCache.delete(sessionId); - this.localEchoTextCache.delete(sessionId); + this._flushedOffsets?.delete(sessionId); this._inputQueue.delete(sessionId); this.ralphStates.delete(sessionId); @@ -8813,6 +8922,8 @@ class ClaudemanApp { document.getElementById('fontSizeDisplay').textContent = size; this.fitAddon.fit(); localStorage.setItem('claudeman-font-size', size); + // Update overlay font cache and re-render at new cell dimensions + this._localEchoOverlay?.refreshFont(); } loadFontSize() { @@ -10261,11 +10372,13 @@ class ClaudemanApp { this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting // Save to server (includes notification prefs for cross-browser persistence) + // Strip device-specific keys — localEchoEnabled is per-platform (touch default differs) + const { localEchoEnabled: _leo, ...serverSettings } = settings; try { await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...settings, notificationPreferences: notifPrefsToSave }) + body: JSON.stringify({ ...serverSettings, notificationPreferences: notifPrefsToSave }) }); // Save model configuration separately @@ -10619,7 +10732,7 @@ class ClaudemanApp { const displayKeys = new Set([ 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', - 'subagentActiveTabOnly', 'tabTwoRows', + 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', ]); // Merge settings: non-display keys always sync from server, // display keys only seed from server when localStorage has no value diff --git a/test/local-echo-basic-test.mjs b/test/local-echo-basic-test.mjs new file mode 100644 index 00000000..7f67fd01 --- /dev/null +++ b/test/local-echo-basic-test.mjs @@ -0,0 +1,590 @@ +/** + * 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('claudeman-app-settings') || '{}'); + s.localEchoEnabled = true; + localStorage.setItem('claudeman-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 new file mode 100644 index 00000000..e51e2649 --- /dev/null +++ b/test/local-echo-daily-test.mjs @@ -0,0 +1,395 @@ +/** + * 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('claudeman-app-settings') || '{}'); + s.localEchoEnabled = true; + localStorage.setItem('claudeman-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 new file mode 100644 index 00000000..711074cd --- /dev/null +++ b/test/local-echo-edge-test.mjs @@ -0,0 +1,533 @@ +/** + * 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('claudeman-app-settings') || '{}'); + s.localEchoEnabled = true; + localStorage.setItem('claudeman-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('claudeman_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 new file mode 100644 index 00000000..897eb257 --- /dev/null +++ b/test/local-echo-mobile-test.mjs @@ -0,0 +1,712 @@ +/** + * 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 Claudeman...'); + 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('claudeman-app-settings') || '{}'); + settings.localEchoEnabled = true; + localStorage.setItem('claudeman-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}"`); + + if (!state?.overlayVisible && !state?.overlayText) { + pass('Overlay hidden after deleting all chars'); + } else { + fail('Overlay still visible after full delete', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`); + } + + // 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 new file mode 100644 index 00000000..12d4e62a --- /dev/null +++ b/test/local-echo-scroll-test.mjs @@ -0,0 +1,398 @@ +/** + * 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('claudeman-app-settings') || '{}'); + s.localEchoEnabled = true; + localStorage.setItem('claudeman-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); +});