From c060b72757d26ad934d035a64e66ea70e89e54da Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 22 Feb 2026 02:05:49 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20local=20echo=20overlay=20=E2=80=94=205?= =?UTF-8?q?=20bugs=20fixed,=2039-test=20suite,=20wrapping=20text=20survive?= =?UTF-8?q?s=20tab=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause bugs fixed in local echo overlay: 1. Stored flushed text as string (_flushedText, _flushedTexts Map) to avoid reading stale terminal buffer 2. Overlay stays visible when pendingText empties but flushed > 0 3. Backspace into flushed text has immediate visual feedback 4. _flushedTexts.delete() added alongside _flushedOffsets.delete() in Enter/Ctrl+C/cleanup 5. OSC terminal responses (xterm color queries) no longer clear flushed text state — was triggered by _handleColorEvent → triggerDataEvent during buffer load after tab switch Added comprehensive test suite (test/local-echo-user-test.mjs): 39 tests across 9 groups including line wrapping, tab switch round-trips, backspace into flushed text, and more. All 6 test suites pass (133 total assertions). Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- package.json | 2 +- src/web/public/app.js | 107 ++- test/local-echo-mobile-test.mjs | 9 +- test/local-echo-user-test.mjs | 1106 +++++++++++++++++++++++++++++++ 5 files changed, 1198 insertions(+), 28 deletions(-) create mode 100644 test/local-echo-user-test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 2209b7df..69f03baa 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.1588 (must match `package.json` for npm publish) +**Version**: 0.1591 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 26212ee4..40c7fc6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1588", + "version": "0.1591", "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 5d11329f..60a2a529 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -897,6 +897,7 @@ class LocalEchoOverlay { screen.appendChild(this.overlay); this.pendingText = ''; this._flushedOffset = 0; // chars flushed to PTY during tab switch (echo may not have arrived yet) + this._flushedText = ''; // stored flushed text string (avoids reading stale terminal buffer) this._storageKey = 'claudeman_local_echo_pending'; // Clear stale text from previous page load. Persisted text can't be // backspaced (flushedOffset is lost on reload) and ghost-merges with @@ -916,7 +917,7 @@ class LocalEchoOverlay { if (buf.viewportY !== buf.baseY) { this.overlay.style.display = 'none'; if (this._scrollTimer) { clearTimeout(this._scrollTimer); this._scrollTimer = null; } - } else if (this.pendingText) { + } else if (this.pendingText || this._flushedOffset > 0) { // Debounce re-render — buffer lines may still be settling if (this._scrollTimer) clearTimeout(this._scrollTimer); this._scrollTimer = setTimeout(() => { @@ -964,13 +965,12 @@ class LocalEchoOverlay { if (this.pendingText.length > 0) { this.pendingText = this.pendingText.slice(0, -1); this._persist(); - if (this.pendingText.length > 0) { + if (this.pendingText.length > 0 || this._flushedOffset > 0) { + // Re-render: either pending text remains, or flushed text exists + // and should stay visible so the user can continue backspacing. this._render(); } else { - // 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. + // Both pending and flushed are empty — hide overlay completely. this._lastRenderKey = ''; this._lastPromptPos = null; this.overlay.innerHTML = ''; @@ -989,6 +989,7 @@ class LocalEchoOverlay { clear() { this.pendingText = ''; this._flushedOffset = 0; + this._flushedText = ''; this._persist(); this._lastRenderKey = ''; this._lastPromptPos = null; @@ -1030,7 +1031,7 @@ class LocalEchoOverlay { } _render() { - if (!this.pendingText) { + if (!this.pendingText && !(this._flushedOffset > 0)) { this.overlay.style.display = 'none'; return; } @@ -1074,13 +1075,19 @@ class LocalEchoOverlay { 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; + // Use stored flushed text (reliable) instead of reading from terminal + // buffer (which may be stale/wrong after async buffer reload on tab switch). + if (this._flushedText && this._flushedText.length === flushed) { + displayText = this._flushedText + this.pendingText; + } else { + // Fallback: read flushed chars from terminal buffer + 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; + } } } @@ -1166,10 +1173,10 @@ class LocalEchoOverlay { // Re-render overlay at current prompt position without clearing pending text. // Used after terminal resets/redraws that move the prompt. rerender() { - if (this.pendingText) this._render(); + if (this.pendingText || this._flushedOffset > 0) this._render(); } - get hasPending() { return this.pendingText.length > 0; } + get hasPending() { return this.pendingText.length > 0 || this._flushedOffset > 0; } dispose() { this.clear(); @@ -2190,10 +2197,32 @@ class ClaudemanApp { // 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--; + // Trim stored flushed text to match + if (this._localEchoOverlay._flushedText) { + this._localEchoOverlay._flushedText = this._localEchoOverlay._flushedText.slice(0, -1); + } 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); + if (prev <= 1) { + this._flushedOffsets.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + } else { + this._flushedOffsets.set(this.activeSessionId, prev - 1); + if (this._flushedTexts?.has(this.activeSessionId)) { + this._flushedTexts.set(this.activeSessionId, + this._flushedTexts.get(this.activeSessionId).slice(0, -1)); + } + } + } + // Immediate visual feedback — re-render overlay with shortened flushed text + this._localEchoOverlay._lastRenderKey = ''; + if (this._localEchoOverlay._flushedOffset > 0) { + this._localEchoOverlay._render(); + } else { + // Fully backspaced through flushed text — hide overlay + this._localEchoOverlay._lastPromptPos = null; + this._localEchoOverlay.overlay.innerHTML = ''; + this._localEchoOverlay.overlay.style.display = 'none'; } this._pendingInput += data; flushInput(); @@ -2205,8 +2234,9 @@ class ClaudemanApp { // Enter: send full buffered text + \r to PTY in one shot const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); - // Clear flushed offset — Enter commits all text + // Clear flushed offset and text — Enter commits all text this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; @@ -2228,12 +2258,24 @@ class ClaudemanApp { return; } if (data.charCodeAt(0) < 32) { + // Skip xterm-generated terminal responses (OSC color queries, etc.). + // These arrive via triggerDataEvent from _handleColorEvent when the + // terminal buffer is loaded and contain escape sequences like \x1b]10;rgb:...\x07. + // They are NOT user input and must not clear flushed text state. + if (data.length > 1 && data.charCodeAt(0) === 27 && data.charCodeAt(1) === 93) { + // OSC sequence (\x1b]) — terminal response, send to PTY but + // don't clear overlay/flushed state (not user input) + this._pendingInput += data; + flushInput(); + return; + } // 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 + // Clear flushed offset and text — control chars (Ctrl+C, Escape, arrows) change // cursor position or abort readline, making flushed text tracking invalid. this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); if (text) { this._pendingInput += text; } @@ -4736,10 +4778,14 @@ class ClaudemanApp { const echoText = this._localEchoOverlay?.pendingText || ''; if (echoText) { this._sendInputAsync(this.activeSessionId, echoText); - // Store flushed length PER SESSION so it's available when switching back + // Store flushed length AND text PER SESSION so it's available when switching back. + // The text is needed because the terminal buffer may be stale after async reload. if (!this._flushedOffsets) this._flushedOffsets = new Map(); + if (!this._flushedTexts) this._flushedTexts = new Map(); const prev = this._flushedOffsets.get(this.activeSessionId) || 0; + const prevText = this._flushedTexts.get(this.activeSessionId) || ''; this._flushedOffsets.set(this.activeSessionId, prev + echoText.length); + this._flushedTexts.set(this.activeSessionId, prevText + echoText); } } this._localEchoOverlay?.clear(); @@ -4753,12 +4799,13 @@ class ClaudemanApp { this.renderSessionTabs(); this._updateLocalEchoState(); - // Restore flushed offset IMMEDIATELY so backspace/typing work during + // Restore flushed offset AND text 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); + this._localEchoOverlay._flushedText = this._flushedTexts?.get(sessionId) || ''; } // Glow the newly-active tab @@ -4834,10 +4881,21 @@ class ClaudemanApp { this.terminal.reset(); } - // Restore flushed offset for this session so the overlay positions correctly - // even before the PTY echo arrives in the terminal buffer. + // Restore flushed offset and text for this session so the overlay positions + // correctly even before the PTY echo arrives in the terminal buffer. if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) { this._localEchoOverlay._flushedOffset = this._flushedOffsets.get(sessionId); + this._localEchoOverlay._flushedText = this._flushedTexts?.get(sessionId) || ''; + // Trigger render after xterm.js finishes processing the buffer data. + // terminal.write('', callback) fires the callback after ALL previously + // queued writes have been parsed — so _findPrompt() can find ❯ in the buffer. + const overlay = this._localEchoOverlay; + this.terminal.write('', () => { + if (overlay._flushedOffset > 0) { + overlay._lastRenderKey = ''; + overlay._render(); + } + }); } // Fire-and-forget resize — don't await to avoid blocking UI. @@ -4922,6 +4980,7 @@ class ClaudemanApp { this.terminalBufferCache.delete(sessionId); this._flushedOffsets?.delete(sessionId); + this._flushedTexts?.delete(sessionId); this._inputQueue.delete(sessionId); this.ralphStates.delete(sessionId); this.ralphClosedSessions.delete(sessionId); diff --git a/test/local-echo-mobile-test.mjs b/test/local-echo-mobile-test.mjs index 897eb257..d4b0bd44 100644 --- a/test/local-echo-mobile-test.mjs +++ b/test/local-echo-mobile-test.mjs @@ -457,10 +457,15 @@ async function main() { console.log(` Overlay visible: ${state?.overlayVisible}`); console.log(` Overlay text: "${state?.overlayText}"`); - if (!state?.overlayVisible && !state?.overlayText) { + // After deleting all typed (pending) text, overlay stays visible because + // flushed text still exists (3 extra backspaces only reduced flushed from 15→12). + // The overlay correctly shows flushed text so the user can keep backspacing. + if (state?.flushedOffset > 0 && state?.overlayVisible && !state?.overlayText) { + pass('Overlay visible with flushed text after deleting pending'); + } else if (!state?.overlayVisible && !state?.overlayText && state?.flushedOffset === 0) { pass('Overlay hidden after deleting all chars'); } else { - fail('Overlay still visible after full delete', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`); + fail('Unexpected state after full delete', `visible=${state?.overlayVisible}, text="${state?.overlayText}", flushed=${state?.flushedOffset}`); } // Verify backspace-into-flushed-text: the 3 extra backspaces should have diff --git a/test/local-echo-user-test.mjs b/test/local-echo-user-test.mjs new file mode 100644 index 00000000..f26c37e0 --- /dev/null +++ b/test/local-echo-user-test.mjs @@ -0,0 +1,1106 @@ +/** + * Local Echo Overlay — Comprehensive User-Scenario Tests (Mobile-First) + * + * 30 tests across 8 groups (A-H), ~108 screenshots. + * Simulates a real mobile user's daily workflow with heavy visual verification. + * + * Screenshots saved to /tmp/local-echo-user/ with descriptive names. + * Error report written to /tmp/local-echo-user/error-report.md. + * + * Usage: timeout 180 node test/local-echo-user-test.mjs 2>&1 | tee /tmp/user-test-output.txt + */ +import { chromium } from 'playwright'; +import { mkdirSync, writeFileSync } from 'fs'; + +const PORT = 3000; +const BASE = `http://localhost:${PORT}`; +const SCREENSHOT_DIR = '/tmp/local-echo-user'; + +// ── Results & Error Reporting ── +const results = []; +const errorReportEntries = []; + +function pass(id, name) { + results.push({ id, name, ok: true }); + console.log(` ✅ ${id}: ${name}`); +} + +function fail(id, name, expected, actual, stateDump, screenshotName, jsErrors) { + results.push({ id, name, ok: false, expected, actual }); + console.log(` ❌ ${id}: ${name} — expected: ${expected}, actual: ${actual}`); + errorReportEntries.push({ id, name, expected, actual, stateDump, screenshotName, jsErrors }); +} + +function writeErrorReport() { + const lines = ['# Local Echo User Test — Error Report\n']; + for (const e of errorReportEntries) { + lines.push(`## ${e.id}: ${e.name}`); + lines.push(`**Status:** FAIL`); + lines.push(`**Step:** ${e.screenshotName || 'N/A'}`); + lines.push(`**Expected:** ${e.expected}`); + lines.push(`**Actual:** ${e.actual}`); + if (e.stateDump) { + lines.push('**State dump:**'); + for (const [k, v] of Object.entries(e.stateDump)) { + lines.push(` ${k}: ${JSON.stringify(v)}`); + } + } + if (e.screenshotName) lines.push(`**Screenshot:** ${e.screenshotName}.png`); + lines.push(`**JS errors:** ${e.jsErrors?.length ? e.jsErrors.join('; ') : 'none'}`); + lines.push(''); + } + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok).length; + const failedIds = results.filter(r => !r.ok).map(r => r.id); + const failedScreenshots = errorReportEntries.map(e => e.screenshotName).filter(Boolean); + lines.push('## Summary'); + lines.push(`- Total: ${results.length} assertions`); + lines.push(`- Passed: ${passed}`); + lines.push(`- Failed: ${failed}`); + lines.push(`- Failed tests: ${failedIds.join(', ') || 'none'}`); + lines.push(`- Screenshots with failures: ${failedScreenshots.map(s => s + '.png').join(', ') || 'none'}`); + writeFileSync(`${SCREENSHOT_DIR}/error-report.md`, lines.join('\n')); +} + +// ── Helpers ── +async function createSession(name) { + const resp = await fetch(`${BASE}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'claude', name }) + }); + if (!resp.ok) throw new Error(`Failed to create '${name}': ${resp.status}`); + const data = await resp.json(); + return data.session?.id || data.id; +} + +async function deleteSession(id) { + await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); +} + +async function waitForReady(id, ms = 15000) { + const start = Date.now(); + while (Date.now() - start < ms) { + const r = await fetch(`${BASE}/api/sessions/${id}`); + if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; } + await new Promise(r => setTimeout(r, 300)); + } + return false; +} + +async function getState(page) { + return page.evaluate(() => { + const app = window.app; + if (!app) return null; + const ov = app._localEchoOverlay; + const buf = app.terminal?.buffer?.active; + + // Cell dimensions + let cellW = 0, cellH = 0; + try { + const dims = app.terminal._core._renderService.dimensions; + cellW = dims.css.cell.width; + cellH = dims.css.cell.height; + } catch {} + + // Children (line divs) + const children = []; + if (ov?.overlay) { + for (const c of ov.overlay.children) { + const spans = c.querySelectorAll('span'); + const text = Array.from(spans).map(s => s.textContent).join(''); + children.push({ + text, + left: c.style.left, + top: c.style.top, + width: c.style.width, + bgColor: c.style.backgroundColor, + spanCount: spans.length, + }); + } + } + + // Cursor block: a direct child of overlay with position:absolute, + // display:inline-block, empty text, and a backgroundColor (cursor color). + // It's appended as the last child of this.overlay (not inside a line div). + let cursor = { exists: false, left: null, top: null, width: null, height: null, bgColor: null }; + if (ov?.overlay) { + for (const child of ov.overlay.children) { + if (child.tagName === 'SPAN' + && child.style.position === 'absolute' + && child.style.display === 'inline-block' + && child.textContent === '') { + cursor = { + exists: true, + left: parseFloat(child.style.left) || 0, + top: parseFloat(child.style.top) || 0, + width: parseFloat(child.style.width) || child.offsetWidth, + height: parseFloat(child.style.height) || child.offsetHeight, + bgColor: child.style.backgroundColor, + }; + break; + } + } + } + + // Prompt line from buffer (bottom-up scan) + let promptLine = null; + if (buf) { + for (let row = app.terminal.rows - 1; row >= 0; row--) { + const line = buf.getLine(buf.viewportY + row); + if (!line) continue; + const text = line.translateToString(true); + if (text.includes('\u276f')) { + promptLine = { text, row, col: text.lastIndexOf('\u276f') }; + break; + } + } + } + + return { + visible: ov?.overlay?.style.display !== 'none', + text: ov?.pendingText || '', + flushed: ov?._flushedOffset || 0, + flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {}, + children, + cursor, + promptLine, + cellW, + cellH, + activeId: app.activeSessionId, + enabled: app._localEchoEnabled, + }; + }); +} + +async function focus(page) { + await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus()); + await page.waitForTimeout(200); +} + +async function switchTab(page, id) { + await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id); + await page.waitForTimeout(1500); +} + +async function waitForPrompt(page, timeoutMs = 15000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const st = await getState(page); + if (st?.promptLine) return true; + await page.waitForTimeout(200); + } + return false; +} + +async function sc(page, name) { + const path = `${SCREENSHOT_DIR}/${name}.png`; + await page.screenshot({ path }); + console.log(` 📸 ${name}.png`); +} + +async function cleanPrompt(page) { + await page.keyboard.down('Control'); + await page.keyboard.press('c'); + await page.keyboard.up('Control'); + await page.waitForTimeout(500); + await focus(page); + await waitForPrompt(page, 5000); +} + +// ── Main ── +async function main() { + try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {} + + console.log('=== Local Echo — Comprehensive User-Scenario Tests ===\n'); + + // Create two sessions + const sA = await createSession('user-test-A'); + const sB = await createSession('user-test-B'); + console.log(` A: ${sA}\n B: ${sB}`); + if (!await waitForReady(sA) || !await waitForReady(sB)) { + await deleteSession(sA); await deleteSession(sB); + throw new Error('Sessions not ready'); + } + console.log(' Both sessions ready\n'); + + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', + isMobile: true, hasTouch: true, + }); + const page = await ctx.newPage(); + const jsErrors = []; + page.on('pageerror', err => jsErrors.push(err.message)); + + try { + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.xterm-screen', { timeout: 15000 }); + await page.waitForTimeout(3000); + + // Enable local echo + await page.evaluate(() => { + const s = JSON.parse(localStorage.getItem('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'); + + // ════════════════════════════════════════════════════════════════ + // GROUP A: Fresh Typing (4 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP A: Fresh Typing ══'); + + // A01: Type single char + console.log('\n--- A01: Type single char ---'); + await sc(page, 'A01a-clean-prompt'); + await page.keyboard.type('x', { delay: 30 }); + await page.waitForTimeout(300); + let st = await getState(page); + await sc(page, 'A01b-typed-x'); + if (st.visible && st.text === 'x' && st.flushed === 0) + pass('A01', 'Type single char'); + else + fail('A01', 'Type single char', 'visible=true, text="x", flushed=0', + `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'A01b-typed-x', jsErrors); + await cleanPrompt(page); + + // A02: Type a word + console.log('\n--- A02: Type a word ---'); + await sc(page, 'A02a-clean-prompt'); + await page.keyboard.type('h', { delay: 60 }); + await page.waitForTimeout(200); + await sc(page, 'A02b-after-h'); + await page.keyboard.type('ello', { delay: 60 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'A02c-after-hello'); + if (st.text === 'hello') + pass('A02', 'Type a word'); + else + fail('A02', 'Type a word', 'text="hello"', `text="${st.text}"`, st, 'A02c-after-hello', jsErrors); + await cleanPrompt(page); + + // A03: Type then backspace one char + console.log('\n--- A03: Type then backspace one char ---'); + await page.keyboard.type('hello', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'A03a-typed-hello'); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'A03b-after-backspace'); + if (st.text === 'hell') + pass('A03', 'Backspace one char'); + else + fail('A03', 'Backspace one char', 'text="hell"', `text="${st.text}"`, st, 'A03b-after-backspace', jsErrors); + await cleanPrompt(page); + + // A04: Type then backspace ALL chars + console.log('\n--- A04: Type then backspace ALL ---'); + await page.keyboard.type('abc', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'A04a-typed-abc'); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'A04b-after-1-backspace'); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'A04c-after-2-backspace'); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'A04d-after-3-backspace'); + if (!st.visible && st.text === '' && st.flushed === 0) + pass('A04', 'Backspace ALL hides overlay'); + else + fail('A04', 'Backspace ALL hides overlay', 'visible=false, text="", flushed=0', + `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'A04d-after-3-backspace', jsErrors); + + // ════════════════════════════════════════════════════════════════ + // GROUP B: Tab Switch Basics (5 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP B: Tab Switch Basics ══'); + + // B05: Type on A, switch to B, check B is clean + console.log('\n--- B05: Type on A, switch to B ---'); + await cleanPrompt(page); + await page.keyboard.type('hello world', { delay: 30 }); + await page.waitForTimeout(300); + await sc(page, 'B05a-typed-on-A'); + await switchTab(page, sB); + st = await getState(page); + await sc(page, 'B05b-on-B'); + if (st.text === '' && (st.flushedMap[sA] === 11 || st.flushedMap[sA] > 0)) + pass('B05', 'Switch to B clears overlay, A flushed'); + else + fail('B05', 'Switch to B clears overlay', 'text="", flushedMap[A]=11', + `text="${st.text}", flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'B05b-on-B', jsErrors); + + // B06: Type on A, switch to B, back to A — flushed restored + overlay visible + console.log('\n--- B06: Round-trip flushed restore ---'); + await switchTab(page, sA); + await focus(page); + await cleanPrompt(page); + await page.keyboard.type('testing', { delay: 30 }); + await page.waitForTimeout(300); + await sc(page, 'B06a-typed-on-A'); + await switchTab(page, sB); + await sc(page, 'B06b-on-B'); + await switchTab(page, sA); + await focus(page); + st = await getState(page); + await sc(page, 'B06c-back-on-A'); + // After fix: overlay stays VISIBLE with flushed text (no pending), so user + // can see what they typed and backspace into it with visual feedback. + if (st.text === '' && st.flushed === 7 && st.visible) + pass('B06', 'Flushed restored + overlay visible'); + else + fail('B06', 'Flushed restored + overlay visible', 'text="", flushed=7, visible=true', + `text="${st.text}", flushed=${st.flushed}, visible=${st.visible}`, st, 'B06c-back-on-A', jsErrors); + + // B07: Type on A, switch, back, type SPACE — no text erasure + console.log('\n--- B07: Space after tab switch ---'); + await cleanPrompt(page); + await page.keyboard.type('test', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'B07a-typed-test'); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + await sc(page, 'B07b-back-on-A'); + await page.keyboard.type(' ', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'B07c-after-space'); + const b07childText = st.children?.[0]?.text || ''; + if (st.text === ' ' && st.flushed === 4 && b07childText.includes('test')) + pass('B07', 'Space after switch — no erasure'); + else + fail('B07', 'Space after switch', 'text=" ", flushed=4, child contains "test"', + `text="${st.text}", flushed=${st.flushed}, child="${b07childText}"`, st, 'B07c-after-space', jsErrors); + + // B08: Type on A, switch, back, type more chars + console.log('\n--- B08: More chars after round-trip ---'); + await cleanPrompt(page); + await page.keyboard.type('fix', { delay: 30 }); + await page.waitForTimeout(200); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + await sc(page, 'B08a-back-on-A'); + await page.keyboard.type(' bug', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'B08b-after-more-chars'); + if (st.text === ' bug' && st.flushed === 3) + pass('B08', 'Type more chars after round-trip'); + else + fail('B08', 'Type more after round-trip', 'text=" bug", flushed=3', + `text="${st.text}", flushed=${st.flushed}`, st, 'B08b-after-more-chars', jsErrors); + + // B09: Type on A, switch, back, backspace 1 char from flushed + // After fix: overlay stays visible with flushed text, backspace gives instant feedback + console.log('\n--- B09: Backspace into flushed ---'); + await cleanPrompt(page); + await page.keyboard.type('hello', { delay: 30 }); + await page.waitForTimeout(200); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + await sc(page, 'B09a-back-with-flushed'); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'B09b-after-backspace'); + const b09childText = st.children?.[0]?.text || ''; + if (st.flushed === 4 && st.visible && b09childText === 'hell') + pass('B09', 'Backspace into flushed with visual feedback'); + else + fail('B09', 'Backspace into flushed', 'flushed=4, visible=true, child="hell"', + `flushed=${st.flushed}, visible=${st.visible}, child="${b09childText}"`, st, 'B09b-after-backspace', jsErrors); + + // ════════════════════════════════════════════════════════════════ + // GROUP C: Tab Switch + Editing (5 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP C: Tab Switch + Editing ══'); + + // C10: Type, switch, back, backspace 3 + type new text + // After fix: overlay renders correctly using stored flushed text + console.log('\n--- C10: Backspace flushed + retype ---'); + await cleanPrompt(page); + await page.keyboard.type('old text', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'C10a-typed'); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + for (let i = 0; i < 3; i++) { + await page.keyboard.press('Backspace'); + await page.waitForTimeout(50); + } + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'C10b-after-3-backspace'); + // After 3 backspaces: flushed "old text" (8) - 3 = "old t" (5) + const c10midChild = st.children?.[0]?.text || ''; + if (st.flushed === 5 && c10midChild === 'old t') + pass('C10a', 'Backspace flushed shows correct remaining text'); + else + fail('C10a', 'Backspace flushed text', 'flushed=5, child="old t"', + `flushed=${st.flushed}, child="${c10midChild}"`, st, 'C10b-after-3-backspace', jsErrors); + await page.keyboard.type('new', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'C10c-after-retype'); + const c10childText = st.children?.[0]?.text || ''; + if (st.flushed === 5 && st.text === 'new' && c10childText === 'old tnew') + pass('C10b', 'Retype after flushed backspace renders correctly'); + else + fail('C10b', 'Retype after flushed backspace', 'flushed=5, text="new", child="old tnew"', + `flushed=${st.flushed}, text="${st.text}", child="${c10childText}"`, st, 'C10c-after-retype', jsErrors); + + // C11: Type, switch, back, delete ALL flushed + console.log('\n--- C11: Delete all flushed ---'); + await cleanPrompt(page); + await page.keyboard.type('abcde', { delay: 30 }); + await page.waitForTimeout(200); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace'); + await page.waitForTimeout(50); + } + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'C11a-after-full-backspace'); + // Extra backspaces — should be no-op + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'C11b-extra-backspace-noop'); + if (!st.visible && st.flushed === 0) + pass('C11', 'Delete all flushed'); + else + fail('C11', 'Delete all flushed', 'visible=false, flushed=0', + `visible=${st.visible}, flushed=${st.flushed}`, st, 'C11b-extra-backspace-noop', jsErrors); + + // C12: Type, switch, back, delete ALL + type fresh + console.log('\n--- C12: Delete all + type fresh ---'); + await cleanPrompt(page); + await page.keyboard.type('remove', { delay: 30 }); + await page.waitForTimeout(200); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + for (let i = 0; i < 8; i++) { + await page.keyboard.press('Backspace'); + await page.waitForTimeout(50); + } + await page.waitForTimeout(200); + await sc(page, 'C12a-fully-deleted'); + await page.keyboard.type('brand new', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'C12b-fresh-text'); + if (st.text === 'brand new' && st.flushed === 0 && st.visible) + pass('C12', 'Fresh text after full delete'); + else + fail('C12', 'Fresh text after full delete', 'text="brand new", flushed=0, visible=true', + `text="${st.text}", flushed=${st.flushed}, visible=${st.visible}`, st, 'C12b-fresh-text', jsErrors); + + // C13: Type on A, switch B, type on B, back to A — no cross-talk + console.log('\n--- C13: No cross-talk between tabs ---'); + await cleanPrompt(page); + await page.keyboard.type('alpha', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'C13a-alpha-on-A'); + await switchTab(page, sB); + await focus(page); + await waitForPrompt(page, 5000); + await page.keyboard.type('beta', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'C13b-beta-on-B'); + await switchTab(page, sA); + await focus(page); + st = await getState(page); + await sc(page, 'C13c-back-on-A'); + if (st.flushedMap[sA] === 5 && st.text === '') + pass('C13', 'No cross-talk between tabs'); + else + fail('C13', 'No cross-talk', 'flushedMap[A]=5, text=""', + `flushedMap=${JSON.stringify(st.flushedMap)}, text="${st.text}"`, st, 'C13c-back-on-A', jsErrors); + + // C14: Both sessions maintain state across double round-trip + console.log('\n--- C14: Double round-trip state ---'); + await cleanPrompt(page); + // Clean B first + await switchTab(page, sB); + await focus(page); + await cleanPrompt(page); + await switchTab(page, sA); + await focus(page); + await cleanPrompt(page); + + await page.keyboard.type('aaa', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'C14a-aaa-on-A'); + await switchTab(page, sB); + await focus(page); + await page.keyboard.type('bbb', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'C14b-bbb-on-B'); + await switchTab(page, sA); + await focus(page); + st = await getState(page); + await sc(page, 'C14c-back-A'); + const c14aFlushed1 = st.flushed; + await page.keyboard.type('+', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'C14d-plus-on-A'); + await switchTab(page, sB); + await focus(page); + st = await getState(page); + await sc(page, 'C14e-back-B'); + const c14bFlushed = st.flushed; + await page.keyboard.type('+', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'C14f-plus-on-B'); + st = await getState(page); + if (c14aFlushed1 >= 3 && c14bFlushed >= 3) + pass('C14', 'Double round-trip state preserved'); + else + fail('C14', 'Double round-trip', 'flushedMap[A]>=3, flushedMap[B]>=3', + `A flushed=${c14aFlushed1}, B flushed=${c14bFlushed}`, st, 'C14f-plus-on-B', jsErrors); + + // ════════════════════════════════════════════════════════════════ + // GROUP D: Enter / Submit (3 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP D: Enter / Submit ══'); + + // D15: Type then Enter clears everything + console.log('\n--- D15: Enter clears overlay ---'); + await switchTab(page, sA); + await focus(page); + await cleanPrompt(page); + await page.keyboard.type('echo test-d15', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'D15a-before-enter'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1000); + await sc(page, 'D15b-after-enter'); + await page.waitForTimeout(1000); + st = await getState(page); + await sc(page, 'D15c-settled'); + if (!st.visible && st.text === '' && st.flushed === 0) + pass('D15', 'Enter clears overlay'); + else + fail('D15', 'Enter clears overlay', 'visible=false, text="", flushed=0', + `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'D15c-settled', jsErrors); + + // D16: Type, switch (flushed), back, Enter + console.log('\n--- D16: Enter after flushed round-trip ---'); + await page.waitForTimeout(3000); // Wait for response + await focus(page); + await waitForPrompt(page, 5000); + await page.keyboard.type('flush-test-d16', { delay: 30 }); + await page.waitForTimeout(200); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + st = await getState(page); + await sc(page, 'D16a-flushed-state'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1000); + st = await getState(page); + await sc(page, 'D16b-after-enter'); + if (st.flushed === 0 && !st.flushedMap[sA]) + pass('D16', 'Enter clears flushed state'); + else + fail('D16', 'Enter clears flushed', 'flushed=0, flushedMap[A]=undefined', + `flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'D16b-after-enter', jsErrors); + + // D17: Type, Enter, wait for response, type on new prompt + console.log('\n--- D17: Type on new prompt after response ---'); + await page.waitForTimeout(4000); // Wait for response + await focus(page); + await waitForPrompt(page, 8000); + await sc(page, 'D17a-before-enter'); + // Don't submit — D15/D16 already submitted. Just type on the new prompt. + await page.keyboard.type('second input', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'D17c-second-input'); + if (st.text === 'second input' && st.flushed === 0) + pass('D17', 'Type on new prompt after response'); + else + fail('D17', 'New prompt typing', 'text="second input", flushed=0', + `text="${st.text}", flushed=${st.flushed}`, st, 'D17c-second-input', jsErrors); + await cleanPrompt(page); + + // ════════════════════════════════════════════════════════════════ + // GROUP E: Ctrl+C / Cancel (3 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP E: Ctrl+C / Cancel ══'); + + // E18: Type then Ctrl+C clears overlay + console.log('\n--- E18: Ctrl+C clears overlay ---'); + await page.keyboard.type('echo test-e18', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'E18a-before-ctrlc'); + await page.keyboard.down('Control'); + await page.keyboard.press('c'); + await page.keyboard.up('Control'); + await page.waitForTimeout(500); + st = await getState(page); + await sc(page, 'E18b-after-ctrlc'); + if (!st.visible && st.text === '' && st.flushed === 0) + pass('E18', 'Ctrl+C clears overlay'); + else + fail('E18', 'Ctrl+C clears overlay', 'visible=false, text="", flushed=0', + `visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'E18b-after-ctrlc', jsErrors); + + // E19: Type, switch (flushed), back, Ctrl+C + console.log('\n--- E19: Ctrl+C after flushed ---'); + await focus(page); + await waitForPrompt(page, 5000); + await page.keyboard.type('flush-then-cancel', { delay: 30 }); + await page.waitForTimeout(200); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + st = await getState(page); + await sc(page, 'E19a-flushed-state'); + await page.keyboard.down('Control'); + await page.keyboard.press('c'); + await page.keyboard.up('Control'); + await page.waitForTimeout(500); + st = await getState(page); + await sc(page, 'E19b-after-ctrlc'); + if (st.flushed === 0 && !st.flushedMap[sA]) + pass('E19', 'Ctrl+C clears flushed state'); + else + fail('E19', 'Ctrl+C flushed clear', 'flushed=0, flushedMap[A]=undefined', + `flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'E19b-after-ctrlc', jsErrors); + + // E20: Ctrl+C on empty — no crash + console.log('\n--- E20: Ctrl+C on empty ---'); + await focus(page); + await waitForPrompt(page, 5000); + const jsErrorsBefore = jsErrors.length; + await sc(page, 'E20a-empty-prompt'); + await page.keyboard.down('Control'); + await page.keyboard.press('c'); + await page.keyboard.up('Control'); + await page.waitForTimeout(500); + st = await getState(page); + await sc(page, 'E20b-after-ctrlc'); + if (!st.visible && jsErrors.length === jsErrorsBefore) + pass('E20', 'Ctrl+C on empty — no crash'); + else + fail('E20', 'Ctrl+C on empty', 'visible=false, no new JS errors', + `visible=${st.visible}, new errors=${jsErrors.length - jsErrorsBefore}`, st, 'E20b-after-ctrlc', jsErrors); + + // ════════════════════════════════════════════════════════════════ + // GROUP F: Cursor Position (3 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP F: Cursor Position ══'); + + // F21: Cursor at end of typed text + console.log('\n--- F21: Cursor at end ---'); + await focus(page); + await waitForPrompt(page, 5000); + await page.keyboard.type('cursor', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'F21a-typed-cursor'); + if (st.cursor.exists) + pass('F21', 'Cursor exists at end of text'); + else + fail('F21', 'Cursor at end', 'cursor.exists=true', + `cursor.exists=${st.cursor.exists}`, st, 'F21a-typed-cursor', jsErrors); + await cleanPrompt(page); + + // F22: Cursor after backspace moves left + console.log('\n--- F22: Cursor moves on backspace ---'); + await page.keyboard.type('abcde', { delay: 30 }); + await page.waitForTimeout(300); + let st1 = await getState(page); + await sc(page, 'F22a-five-chars'); + const cursorLeftBefore = st1.cursor.left; + await page.keyboard.press('Backspace'); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'F22b-after-backspace'); + if (st.cursor.exists && st.cursor.left < cursorLeftBefore) + pass('F22', 'Cursor moves left on backspace'); + else + fail('F22', 'Cursor moves left', `cursor.left < ${cursorLeftBefore}`, + `cursor.left=${st.cursor.left}`, st, 'F22b-after-backspace', jsErrors); + await cleanPrompt(page); + + // F23: Cursor after tab switch + retype + console.log('\n--- F23: Cursor after flushed + pending ---'); + await page.keyboard.type('hi', { delay: 30 }); + await page.waitForTimeout(200); + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + await page.keyboard.type('!!', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'F23a-flushed-plus-pending'); + // Cursor should exist — that's the key assertion + if (st.cursor.exists) + pass('F23', 'Cursor correct after flushed + pending'); + else + fail('F23', 'Cursor after flushed+pending', 'cursor.exists=true', + `cursor.exists=${st.cursor.exists}`, st, 'F23a-flushed-plus-pending', jsErrors); + await cleanPrompt(page); + + // ════════════════════════════════════════════════════════════════ + // GROUP G: Visual Consistency (4 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP G: Visual Consistency ══'); + + // G24: Overlay position stable between keystrokes + console.log('\n--- G24: Position stable between keystrokes ---'); + await page.keyboard.type('a', { delay: 30 }); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'G24a-first-char'); + const g24posA = st.children?.[0]?.left; + const g24topA = st.children?.[0]?.top; + await page.keyboard.type('b', { delay: 30 }); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'G24b-second-char'); + const g24posB = st.children?.[0]?.left; + const g24topB = st.children?.[0]?.top; + await page.keyboard.type('c', { delay: 30 }); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'G24c-third-char'); + const g24posC = st.children?.[0]?.left; + const g24topC = st.children?.[0]?.top; + if (g24posA === g24posB && g24posB === g24posC && g24topA === g24topB && g24topB === g24topC) + pass('G24', 'Overlay position stable between keystrokes'); + else + fail('G24', 'Position stable', `all same left/top`, + `left: ${g24posA}, ${g24posB}, ${g24posC}; top: ${g24topA}, ${g24topB}, ${g24topC}`, st, 'G24c-third-char', jsErrors); + await cleanPrompt(page); + + // G25: Overlay position stable across tab switch + console.log('\n--- G25: Position stable across tab switch ---'); + await page.keyboard.type('pos test', { delay: 30 }); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'G25a-before-switch'); + const g25leftBefore = st.children?.[0]?.left; + await switchTab(page, sB); + await switchTab(page, sA); + await focus(page); + await page.keyboard.type(' more', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'G25b-after-switch-type'); + const g25leftAfter = st.children?.[0]?.left; + if (g25leftBefore === g25leftAfter) + pass('G25', 'Position stable across tab switch'); + else + fail('G25', 'Position across switch', `left unchanged`, + `before=${g25leftBefore}, after=${g25leftAfter}`, st, 'G25b-after-switch-type', jsErrors); + await cleanPrompt(page); + + // G26: Overlay background covers full width + console.log('\n--- G26: Overlay background width ---'); + await page.keyboard.type('x', { delay: 30 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'G26a-overlay-width'); + const childWidth = st.children?.[0]?.width; + if (childWidth && parseFloat(childWidth) > 0) + pass('G26', 'Overlay has non-zero width'); + else + fail('G26', 'Overlay width', 'width > 0', + `width="${childWidth}"`, st, 'G26a-overlay-width', jsErrors); + await cleanPrompt(page); + + // G27: No JS errors across all tests + console.log('\n--- G27: No JS errors ---'); + await sc(page, 'G27-final-state'); + if (jsErrors.length === 0) + pass('G27', 'No JS errors'); + else + fail('G27', 'No JS errors', '0 errors', + `${jsErrors.length} errors: ${jsErrors.slice(0, 3).join('; ')}`, null, 'G27-final-state', jsErrors); + + // ════════════════════════════════════════════════════════════════ + // GROUP H: Edge Cases (3 tests) + // ════════════════════════════════════════════════════════════════ + console.log('\n══ GROUP H: Edge Cases ══'); + + // H28: Paste multi-char text + console.log('\n--- H28: Paste simulation ---'); + await focus(page); + await waitForPrompt(page, 5000); + await page.evaluate(() => { + window.app?._localEchoOverlay?.appendText('pasted'); + }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'H28a-after-paste'); + if (st.text === 'pasted' && st.visible) + pass('H28', 'Paste appends text'); + else + fail('H28', 'Paste appends text', 'text="pasted", visible=true', + `text="${st.text}", visible=${st.visible}`, st, 'H28a-after-paste', jsErrors); + await cleanPrompt(page); + + // H29: Rapid type-backspace-type + console.log('\n--- H29: Rapid type-backspace-type ---'); + await page.keyboard.type('hello', { delay: 15 }); + await page.waitForTimeout(100); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(100); + await page.keyboard.type('p me', { delay: 15 }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'H29a-after-rapid'); + if (st.text === 'help me') + pass('H29', 'Rapid type-backspace-type'); + else + fail('H29', 'Rapid editing', 'text="help me"', + `text="${st.text}"`, st, 'H29a-after-rapid', jsErrors); + await cleanPrompt(page); + + // H30: Page reload clears all state + console.log('\n--- H30: Page reload clears state ---'); + await page.keyboard.type('will be lost', { delay: 30 }); + await page.waitForTimeout(200); + await sc(page, 'H30a-before-reload'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.xterm-screen', { timeout: 10000 }); + await page.waitForTimeout(3000); + // Re-enable local echo after reload + await page.evaluate(() => { + window.app?._updateLocalEchoState?.(); + }); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'H30b-after-reload'); + if (st.text === '' && st.flushed === 0 && Object.keys(st.flushedMap).length === 0) + pass('H30', 'Reload clears all state'); + else + fail('H30', 'Reload clears state', 'text="", flushed=0, flushedMap empty', + `text="${st.text}", flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'H30b-after-reload', jsErrors); + + // ══════════════════════════════════════════════════════════════ + // GROUP I: Long Text / Line Wrapping + // ══════════════════════════════════════════════════════════════ + console.log('\n══ GROUP I: Long Text / Line Wrapping ══'); + + // Need a prompt to work with after H30 reload + // Re-select session A and wait for prompt + await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sA); + await page.waitForTimeout(2000); + await page.evaluate(() => { + const s = JSON.parse(localStorage.getItem('claudeman-app-settings') || '{}'); + s.localEchoEnabled = true; + localStorage.setItem('claudeman-app-settings', JSON.stringify(s)); + window.app?._updateLocalEchoState?.(); + }); + await page.waitForTimeout(300); + await focus(page); + await waitForPrompt(page); + + // Get terminal columns to know the wrap point + const termCols = await page.evaluate(() => window.app?.terminal?.cols || 80); + // Prompt uses "❯ " (2 chars), so first line has (cols - 2) available chars + const firstLineCols = termCols - 2; + + // I31: Type text that fills exactly first line (no wrap) + console.log('\n--- I31: Type text that fills first line ---'); + await cleanPrompt(page); + await focus(page); + await waitForPrompt(page); + const fillText = 'A'.repeat(firstLineCols); + await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), fillText); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'I31a-full-first-line'); + if (st.visible && st.text === fillText && st.children.length >= 1) { + pass('I31', 'Full first line renders correctly'); + } else { + fail('I31', 'Full first line', `visible=true, text=${fillText.length} chars, children>=1`, + `visible=${st.visible}, text=${st.text.length} chars, children=${st.children.length}`, st, 'I31a-full-first-line', jsErrors); + } + await cleanPrompt(page); + + // I32: Type text that wraps to second line + console.log('\n--- I32: Type text that wraps to second line ---'); + await focus(page); + await waitForPrompt(page); + const wrapText = 'B'.repeat(firstLineCols + 5); // 5 chars on second line + await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'I32a-wrapped-to-line-2'); + // Should have 2 line divs + 1 cursor span = 3 children + const lineChildren = st.children.filter(c => c.spanCount > 0); + if (st.visible && st.text === wrapText && lineChildren.length === 2) { + pass('I32', 'Text wraps to second line correctly'); + } else { + fail('I32', 'Text wraps', `visible=true, text=${wrapText.length} chars, 2 line divs`, + `visible=${st.visible}, text=${st.text.length} chars, line divs=${lineChildren.length}`, st, 'I32a-wrapped-to-line-2', jsErrors); + } + + // Check cursor is on the second line + if (st.cursor.exists && st.cursor.top > 0) { + pass('I32c', 'Cursor on second line'); + } else { + fail('I32c', 'Cursor position', 'cursor.top > 0 (second line)', + `cursor.top=${st.cursor.top}`, st, 'I32a-wrapped-to-line-2', jsErrors); + } + await cleanPrompt(page); + + // I33: Type wrapping text, backspace back to first line + console.log('\n--- I33: Wrap then backspace to first line ---'); + await focus(page); + await waitForPrompt(page); + const wrapText2 = 'C'.repeat(firstLineCols + 3); + await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText2); + await page.waitForTimeout(200); + await sc(page, 'I33a-wrapped'); + // Backspace the 3 chars on second line + for (let i = 0; i < 3; i++) { + await page.keyboard.press('Backspace'); + await page.waitForTimeout(30); + } + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'I33b-back-to-one-line'); + const lineChildrenAfter = st.children.filter(c => c.spanCount > 0); + if (st.visible && st.text.length === firstLineCols && lineChildrenAfter.length === 1) { + pass('I33', 'Backspace from second line to first'); + } else { + fail('I33', 'Backspace to first line', `text=${firstLineCols} chars, 1 line div`, + `text=${st.text.length} chars, line divs=${lineChildrenAfter.length}`, st, 'I33b-back-to-one-line', jsErrors); + } + await cleanPrompt(page); + + // I34: Wrapping text survives tab switch + console.log('\n--- I34: Wrapping text after tab switch ---'); + await focus(page); + await waitForPrompt(page); + const wrapLen34 = firstLineCols + 8; + const wrapText3 = 'D'.repeat(wrapLen34); + // Use appendText for instant injection (avoids keyboard timing issues) + await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText3); + await page.waitForTimeout(200); + // Verify text is buffered before switching + st = await getState(page); + await sc(page, 'I34a-wrapped-before-switch'); + console.log(` buffered: text=${st.text.length} chars, visible=${st.visible}`); + // Switch to B + await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sB); + await page.waitForTimeout(1500); + // Switch back to A + await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sA); + await page.waitForTimeout(2500); + st = await getState(page); + await sc(page, 'I34b-after-round-trip'); + // After round-trip, flushed should contain the wrapping text + if (st.flushed >= wrapLen34) { + pass('I34a', 'Flushed wrapping text preserved'); + } else { + fail('I34a', 'Flushed wrap text', `flushed>=${wrapLen34}`, + `flushed=${st.flushed}`, st, 'I34b-after-round-trip', jsErrors); + } + // Overlay should be visible with flushed text + if (st.visible && st.children.length >= 2) { + pass('I34b', 'Flushed wrapping text renders multi-line'); + } else { + fail('I34b', 'Flushed wrap render', 'visible=true, children>=2', + `visible=${st.visible}, children=${st.children.length}`, st, 'I34b-after-round-trip', jsErrors); + } + // Type more to extend the wrap + await focus(page); + await page.keyboard.type('EE', { delay: 30 }); + await page.waitForTimeout(200); + st = await getState(page); + await sc(page, 'I34c-extended-wrap'); + if (st.text === 'EE' && st.flushed >= wrapLen34) { + pass('I34c', 'Extended wrapping after tab switch'); + } else { + fail('I34c', 'Extended wrap', `text="EE", flushed>=${wrapLen34}`, + `text="${st.text}", flushed=${st.flushed}`, st, 'I34c-extended-wrap', jsErrors); + } + await cleanPrompt(page); + + // I35: Very long text (3+ lines) + console.log('\n--- I35: Very long text (3+ lines) ---'); + await focus(page); + await waitForPrompt(page); + const longText = 'F'.repeat(firstLineCols + termCols + 5); // first line + full second + 5 on third + await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), longText); + await page.waitForTimeout(300); + st = await getState(page); + await sc(page, 'I35a-three-lines'); + const longLineChildren = st.children.filter(c => c.spanCount > 0); + if (st.visible && st.text === longText && longLineChildren.length === 3) { + pass('I35', 'Very long text renders 3 lines'); + } else { + fail('I35', '3-line text', `visible=true, text=${longText.length} chars, 3 line divs`, + `visible=${st.visible}, text=${st.text.length} chars, line divs=${longLineChildren.length}`, st, 'I35a-three-lines', jsErrors); + } + await cleanPrompt(page); + + // ════════════════════════════════════════════════════════════════ + // SUMMARY + // ════════════════════════════════════════════════════════════════ + console.log('\n════════════════════════════════════════'); + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok).length; + console.log(` ${passed} passed, ${failed} failed out of ${results.length} assertions`); + console.log(` Screenshots: ${SCREENSHOT_DIR}/`); + + if (failed > 0) { + console.log('\n FAILURES:'); + for (const r of results.filter(r => !r.ok)) { + console.log(` ❌ ${r.id}: ${r.name}`); + } + } + + if (jsErrors.length > 0) { + console.log(`\n JS Errors (${jsErrors.length}):`); + for (const e of jsErrors.slice(0, 5)) console.log(` ⚠️ ${e}`); + } + + console.log('════════════════════════════════════════'); + + } finally { + // Write error report + writeErrorReport(); + console.log(`\n Error report: ${SCREENSHOT_DIR}/error-report.md`); + + await browser.close(); + console.log('\nCleaning up sessions...'); + await deleteSession(sA); + await deleteSession(sB); + console.log('Done.\n'); + } +} + +main().catch(err => { console.error('Test failed:', err); process.exit(1); });