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
This commit is contained in:
arkon
2026-02-21 23:37:29 +01:00
parent 6c75432d74
commit c820acc96e
8 changed files with 2786 additions and 45 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+156 -43
View File
@@ -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<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
this.localEchoTextCache = new Map(); // Map<sessionId, string> — 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
+590
View File
@@ -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); });
+395
View File
@@ -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); });
+533
View File
@@ -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); });
+712
View File
@@ -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);
});
+398
View File
@@ -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);
});