mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Root cause bugs fixed in local echo overlay: 1. Stored flushed text as string (_flushedText, _flushedTexts Map) to avoid reading stale terminal buffer 2. Overlay stays visible when pendingText empties but flushed > 0 3. Backspace into flushed text has immediate visual feedback 4. _flushedTexts.delete() added alongside _flushedOffsets.delete() in Enter/Ctrl+C/cleanup 5. OSC terminal responses (xterm color queries) no longer clear flushed text state — was triggered by _handleColorEvent → triggerDataEvent during buffer load after tab switch Added comprehensive test suite (test/local-echo-user-test.mjs): 39 tests across 9 groups including line wrapping, tab switch round-trips, backspace into flushed text, and more. All 6 test suites pass (133 total assertions). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
718 lines
27 KiB
JavaScript
718 lines
27 KiB
JavaScript
/**
|
||
* 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}"`);
|
||
|
||
// After deleting all typed (pending) text, overlay stays visible because
|
||
// flushed text still exists (3 extra backspaces only reduced flushed from 15→12).
|
||
// The overlay correctly shows flushed text so the user can keep backspacing.
|
||
if (state?.flushedOffset > 0 && state?.overlayVisible && !state?.overlayText) {
|
||
pass('Overlay visible with flushed text after deleting pending');
|
||
} else if (!state?.overlayVisible && !state?.overlayText && state?.flushedOffset === 0) {
|
||
pass('Overlay hidden after deleting all chars');
|
||
} else {
|
||
fail('Unexpected state after full delete', `visible=${state?.overlayVisible}, text="${state?.overlayText}", flushed=${state?.flushedOffset}`);
|
||
}
|
||
|
||
// Verify backspace-into-flushed-text: the 3 extra backspaces should have
|
||
// decremented flushedOffset from 15 to 12 and sent \x7f to PTY.
|
||
const expectedFlushedAfterDelete = 15 - 3; // original 15, minus 3 extras
|
||
console.log(` Flushed offset after delete: ${state?.flushedOffset} (expected ${expectedFlushedAfterDelete})`);
|
||
if (state?.flushedOffset === expectedFlushedAfterDelete) {
|
||
pass('Backspace into flushed text decrements offset correctly');
|
||
} else {
|
||
fail('Flushed offset after backspace', `expected ${expectedFlushedAfterDelete}, got ${state?.flushedOffset}`);
|
||
}
|
||
|
||
// ── TEST 9: Type, submit with Enter, verify overlay clears ──
|
||
console.log('\n--- Test 9: Enter submits and clears overlay ---');
|
||
const enterText = 'echo submit-test';
|
||
await page.keyboard.type(enterText, { delay: 50 });
|
||
await page.waitForTimeout(300);
|
||
|
||
state = await getOverlayState(page);
|
||
await screenshot(page, '09-before-enter');
|
||
|
||
if (state?.overlayText === enterText) {
|
||
pass('Text buffered before Enter');
|
||
} else {
|
||
info('Pre-enter text', `"${state?.overlayText}"`);
|
||
}
|
||
|
||
await page.keyboard.press('Enter');
|
||
await page.waitForTimeout(1000);
|
||
|
||
state = await getOverlayState(page);
|
||
await screenshot(page, '10-after-enter');
|
||
|
||
console.log(` Overlay visible: ${state?.overlayVisible}`);
|
||
console.log(` Overlay text: "${state?.overlayText}"`);
|
||
|
||
if (!state?.overlayVisible && !state?.overlayText) {
|
||
pass('Overlay cleared after Enter');
|
||
} else {
|
||
fail('Overlay not cleared after Enter', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
|
||
}
|
||
|
||
// ── TEST 10: Rapid tab switching with pending text ──
|
||
console.log('\n--- Test 10: Rapid tab switching ---');
|
||
await page.keyboard.type('rapid-switch-text', { delay: 30 });
|
||
await page.waitForTimeout(200);
|
||
await screenshot(page, '11-before-rapid-switch');
|
||
|
||
// Rapid switch: A → B → A
|
||
await clickTab(page, sessionB);
|
||
await page.waitForTimeout(300);
|
||
await screenshot(page, '12-rapid-switch-to-b');
|
||
|
||
await clickTab(page, sessionA);
|
||
await page.waitForTimeout(300);
|
||
await screenshot(page, '13-rapid-switch-back-to-a');
|
||
|
||
state = await getOverlayState(page);
|
||
console.log(` After rapid switch — overlay text: "${state?.overlayText}"`);
|
||
console.log(` Flushed offsets: ${JSON.stringify(state?.flushedOffsets)}`);
|
||
|
||
// Text should have been flushed to PTY, overlay should be clear
|
||
if (!state?.overlayText || state?.overlayText === '') {
|
||
pass('Rapid switch: overlay clear after return');
|
||
} else {
|
||
fail('Rapid switch: overlay has stale text', `"${state?.overlayText}"`);
|
||
}
|
||
|
||
// ── TEST 11: Font detail check — pixel-level ──
|
||
console.log('\n--- Test 11: Detailed font comparison ---');
|
||
await page.keyboard.type('font-check', { delay: 50 });
|
||
await page.waitForTimeout(300);
|
||
|
||
const fontDetail = await page.evaluate(() => {
|
||
const app = window.app;
|
||
const overlay = app?._localEchoOverlay;
|
||
if (!overlay) return null;
|
||
|
||
// Overlay span
|
||
const oSpan = overlay.overlay.querySelector('span');
|
||
// Terminal character
|
||
const tRows = document.querySelector('.xterm-rows');
|
||
// Find a row with actual text
|
||
let tSpan = null;
|
||
if (tRows) {
|
||
const allSpans = tRows.querySelectorAll('span');
|
||
for (const s of allSpans) {
|
||
if (s.textContent?.trim()) { tSpan = s; break; }
|
||
}
|
||
}
|
||
|
||
const getDetailedFont = (el) => {
|
||
if (!el) return null;
|
||
const cs = getComputedStyle(el);
|
||
return {
|
||
fontFamily: cs.fontFamily,
|
||
fontSize: cs.fontSize,
|
||
fontWeight: cs.fontWeight,
|
||
fontStyle: cs.fontStyle,
|
||
letterSpacing: cs.letterSpacing,
|
||
lineHeight: cs.lineHeight,
|
||
color: cs.color,
|
||
textRendering: cs.textRendering,
|
||
webkitFontSmoothing: cs.webkitFontSmoothing || cs['-webkit-font-smoothing'],
|
||
fontKerning: cs.fontKerning,
|
||
fontVariantLigatures: cs.fontVariantLigatures,
|
||
};
|
||
};
|
||
|
||
// Also check xterm.js options
|
||
const xtermOpts = {
|
||
fontFamily: app.terminal?.options?.fontFamily,
|
||
fontSize: app.terminal?.options?.fontSize,
|
||
fontWeight: app.terminal?.options?.fontWeight,
|
||
letterSpacing: app.terminal?.options?.letterSpacing,
|
||
};
|
||
|
||
// Cell dimensions
|
||
let cellDims = null;
|
||
try {
|
||
const dims = app.terminal._core._renderService.dimensions;
|
||
cellDims = {
|
||
cellWidth: dims.css.cell.width,
|
||
cellHeight: dims.css.cell.height,
|
||
};
|
||
} catch {}
|
||
|
||
return {
|
||
overlaySpan: getDetailedFont(oSpan),
|
||
terminalSpan: getDetailedFont(tSpan),
|
||
terminalRows: getDetailedFont(tRows),
|
||
xtermOptions: xtermOpts,
|
||
cellDims,
|
||
// Check overlay cached values vs actual
|
||
overlayCached: {
|
||
fontFamily: overlay._fontFamily,
|
||
fontSize: overlay._fontSize,
|
||
fontWeight: overlay._fontWeight,
|
||
color: overlay._color,
|
||
letterSpacing: overlay._letterSpacing,
|
||
bg: overlay._bg,
|
||
},
|
||
};
|
||
});
|
||
await screenshot(page, '14-font-detail-check');
|
||
|
||
console.log(' xterm.js options:', JSON.stringify(fontDetail?.xtermOptions, null, 4));
|
||
console.log(' Cell dims:', JSON.stringify(fontDetail?.cellDims, null, 4));
|
||
console.log(' Overlay cached:', JSON.stringify(fontDetail?.overlayCached, null, 4));
|
||
console.log(' Overlay span computed:', JSON.stringify(fontDetail?.overlaySpan, null, 4));
|
||
console.log(' Terminal span computed:', JSON.stringify(fontDetail?.terminalSpan, null, 4));
|
||
console.log(' Terminal rows computed:', JSON.stringify(fontDetail?.terminalRows, null, 4));
|
||
|
||
// Check specific mismatches
|
||
if (fontDetail?.overlaySpan && fontDetail?.terminalSpan) {
|
||
const o = fontDetail.overlaySpan;
|
||
const t = fontDetail.terminalSpan;
|
||
const mismatches = [];
|
||
for (const key of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'fontKerning']) {
|
||
if (o[key] !== t[key]) {
|
||
mismatches.push(`${key}: overlay="${o[key]}" vs terminal="${t[key]}"`);
|
||
}
|
||
}
|
||
if (mismatches.length === 0) {
|
||
pass('Detailed font comparison matches');
|
||
} else {
|
||
fail('Font mismatches found', mismatches.join('; '));
|
||
}
|
||
} else {
|
||
info('Font detail check', 'Could not get computed styles for comparison');
|
||
}
|
||
|
||
// Clean up the typed text
|
||
await page.keyboard.press('Backspace');
|
||
await page.waitForTimeout(50);
|
||
for (let i = 0; i < 'font-check'.length; i++) {
|
||
await page.keyboard.press('Backspace');
|
||
await page.waitForTimeout(20);
|
||
}
|
||
|
||
// ── TEST 12: Long text that wraps ──
|
||
console.log('\n--- Test 12: Long text wrapping ---');
|
||
// Type enough to wrap past terminal width
|
||
const longText = 'A'.repeat(80) + 'B'.repeat(40); // 120 chars — likely wraps on mobile
|
||
await page.keyboard.type(longText, { delay: 5 });
|
||
await page.waitForTimeout(500);
|
||
|
||
state = await getOverlayState(page);
|
||
await screenshot(page, '15-long-text-wrap');
|
||
|
||
console.log(` Overlay text length: ${state?.overlayText?.length}`);
|
||
console.log(` Overlay children (lines): ${state?.overlayChildren?.length}`);
|
||
if (state?.overlayChildren) {
|
||
for (const [i, child] of state.overlayChildren.entries()) {
|
||
console.log(` Line ${i}: "${child.text}" (${child.spanCount} spans, left=${child.left}, top=${child.top})`);
|
||
}
|
||
}
|
||
|
||
if (state?.overlayText === longText) {
|
||
pass('Long text stored correctly');
|
||
} else {
|
||
fail('Long text storage', `expected ${longText.length} chars, got ${state?.overlayText?.length}`);
|
||
}
|
||
|
||
if (state?.overlayChildren && state.overlayChildren.length > 1) {
|
||
pass(`Long text wraps into ${state.overlayChildren.length} lines`);
|
||
} else {
|
||
fail('Long text wrapping', `only ${state?.overlayChildren?.length} line(s)`);
|
||
}
|
||
|
||
// Delete the long text
|
||
await page.keyboard.press('Enter');
|
||
await page.waitForTimeout(500);
|
||
|
||
// Final screenshot
|
||
await screenshot(page, '16-final-state');
|
||
|
||
// ── SUMMARY ──
|
||
console.log('\n=== SUMMARY ===');
|
||
const passes = results.filter(r => r.status === 'PASS').length;
|
||
const fails = results.filter(r => r.status === 'FAIL').length;
|
||
const infos = results.filter(r => r.status === 'INFO').length;
|
||
console.log(` ${passes} passed, ${fails} failed, ${infos} info`);
|
||
console.log(` Screenshots saved to ${SCREENSHOT_DIR}/`);
|
||
|
||
if (fails > 0) {
|
||
console.log('\n FAILURES:');
|
||
for (const r of results.filter(r => r.status === 'FAIL')) {
|
||
console.log(` ❌ ${r.name}: ${r.detail}`);
|
||
}
|
||
}
|
||
|
||
if (errors.length > 0) {
|
||
console.log(`\n Console errors (${errors.length}):`);
|
||
for (const e of errors.slice(0, 10)) console.log(` ${e}`);
|
||
}
|
||
|
||
} finally {
|
||
await browser.close();
|
||
console.log('\nCleaning up sessions...');
|
||
await deleteSession(sessionA);
|
||
await deleteSession(sessionB);
|
||
}
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('Test failed:', err);
|
||
process.exit(1);
|
||
});
|