Files
Codeman/test/local-echo-user-test.mjs
T

1106 lines
47 KiB
JavaScript
Raw Normal View History

/**
* Local Echo Overlay — Comprehensive User-Scenario Tests (Mobile-First)
*
* 30 tests across 8 groups (A-H), ~108 screenshots.
* Simulates a real mobile user's daily workflow with heavy visual verification.
*
* Screenshots saved to /tmp/local-echo-user/ with descriptive names.
* Error report written to /tmp/local-echo-user/error-report.md.
*
* Usage: timeout 180 node test/local-echo-user-test.mjs 2>&1 | tee /tmp/user-test-output.txt
*/
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'fs';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-user';
// ── Results & Error Reporting ──
const results = [];
const errorReportEntries = [];
function pass(id, name) {
results.push({ id, name, ok: true });
console.log(` ✅ ${id}: ${name}`);
}
function fail(id, name, expected, actual, stateDump, screenshotName, jsErrors) {
results.push({ id, name, ok: false, expected, actual });
console.log(` ❌ ${id}: ${name} — expected: ${expected}, actual: ${actual}`);
errorReportEntries.push({ id, name, expected, actual, stateDump, screenshotName, jsErrors });
}
function writeErrorReport() {
const lines = ['# Local Echo User Test — Error Report\n'];
for (const e of errorReportEntries) {
lines.push(`## ${e.id}: ${e.name}`);
lines.push(`**Status:** FAIL`);
lines.push(`**Step:** ${e.screenshotName || 'N/A'}`);
lines.push(`**Expected:** ${e.expected}`);
lines.push(`**Actual:** ${e.actual}`);
if (e.stateDump) {
lines.push('**State dump:**');
for (const [k, v] of Object.entries(e.stateDump)) {
lines.push(` ${k}: ${JSON.stringify(v)}`);
}
}
if (e.screenshotName) lines.push(`**Screenshot:** ${e.screenshotName}.png`);
lines.push(`**JS errors:** ${e.jsErrors?.length ? e.jsErrors.join('; ') : 'none'}`);
lines.push('');
}
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
const failedIds = results.filter(r => !r.ok).map(r => r.id);
const failedScreenshots = errorReportEntries.map(e => e.screenshotName).filter(Boolean);
lines.push('## Summary');
lines.push(`- Total: ${results.length} assertions`);
lines.push(`- Passed: ${passed}`);
lines.push(`- Failed: ${failed}`);
lines.push(`- Failed tests: ${failedIds.join(', ') || 'none'}`);
lines.push(`- Screenshots with failures: ${failedScreenshots.map(s => s + '.png').join(', ') || 'none'}`);
writeFileSync(`${SCREENSHOT_DIR}/error-report.md`, lines.join('\n'));
}
// ── Helpers ──
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed to create '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const ov = app._localEchoOverlay;
const buf = app.terminal?.buffer?.active;
// Cell dimensions
let cellW = 0, cellH = 0;
try {
const dims = app.terminal._core._renderService.dimensions;
cellW = dims.css.cell.width;
cellH = dims.css.cell.height;
} catch {}
// Children (line divs)
const children = [];
if (ov?.overlay) {
for (const c of ov.overlay.children) {
const spans = c.querySelectorAll('span');
const text = Array.from(spans).map(s => s.textContent).join('');
children.push({
text,
left: c.style.left,
top: c.style.top,
width: c.style.width,
bgColor: c.style.backgroundColor,
spanCount: spans.length,
});
}
}
// Cursor block: a direct child <span> of overlay with position:absolute,
// display:inline-block, empty text, and a backgroundColor (cursor color).
// It's appended as the last child of this.overlay (not inside a line div).
let cursor = { exists: false, left: null, top: null, width: null, height: null, bgColor: null };
if (ov?.overlay) {
for (const child of ov.overlay.children) {
if (child.tagName === 'SPAN'
&& child.style.position === 'absolute'
&& child.style.display === 'inline-block'
&& child.textContent === '') {
cursor = {
exists: true,
left: parseFloat(child.style.left) || 0,
top: parseFloat(child.style.top) || 0,
width: parseFloat(child.style.width) || child.offsetWidth,
height: parseFloat(child.style.height) || child.offsetHeight,
bgColor: child.style.backgroundColor,
};
break;
}
}
}
// Prompt line from buffer (bottom-up scan)
let promptLine = null;
if (buf) {
for (let row = app.terminal.rows - 1; row >= 0; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
if (text.includes('\u276f')) {
promptLine = { text, row, col: text.lastIndexOf('\u276f') };
break;
}
}
}
return {
visible: ov?.overlay?.style.display !== 'none',
text: ov?.pendingText || '',
flushed: ov?._flushedOffset || 0,
flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
children,
cursor,
promptLine,
cellW,
cellH,
activeId: app.activeSessionId,
enabled: app._localEchoEnabled,
};
});
}
async function focus(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function switchTab(page, id) {
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id);
await page.waitForTimeout(1500);
}
async function waitForPrompt(page, timeoutMs = 15000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const st = await getState(page);
if (st?.promptLine) return true;
await page.waitForTimeout(200);
}
return false;
}
async function sc(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path });
console.log(` 📸 ${name}.png`);
}
async function cleanPrompt(page) {
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
await focus(page);
await waitForPrompt(page, 5000);
}
// ── Main ──
async function main() {
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Comprehensive User-Scenario Tests ===\n');
// Create two sessions
const sA = await createSession('user-test-A');
const sB = await createSession('user-test-B');
console.log(` A: ${sA}\n B: ${sB}`);
if (!await waitForReady(sA) || !await waitForReady(sB)) {
await deleteSession(sA); await deleteSession(sB);
throw new Error('Sessions not ready');
}
console.log(' Both sessions ready\n');
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
const jsErrors = [];
page.on('pageerror', err => jsErrors.push(err.message));
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 15000 });
await page.waitForTimeout(3000);
// Enable local echo
await page.evaluate(() => {
2026-02-26 16:44:34 +01:00
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
2026-02-26 16:44:34 +01:00
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
// Select tab A, wait for prompt
await switchTab(page, sA);
await focus(page);
if (!await waitForPrompt(page)) throw new Error('No prompt on A');
// ════════════════════════════════════════════════════════════════
// GROUP A: Fresh Typing (4 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP A: Fresh Typing ══');
// A01: Type single char
console.log('\n--- A01: Type single char ---');
await sc(page, 'A01a-clean-prompt');
await page.keyboard.type('x', { delay: 30 });
await page.waitForTimeout(300);
let st = await getState(page);
await sc(page, 'A01b-typed-x');
if (st.visible && st.text === 'x' && st.flushed === 0)
pass('A01', 'Type single char');
else
fail('A01', 'Type single char', 'visible=true, text="x", flushed=0',
`visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'A01b-typed-x', jsErrors);
await cleanPrompt(page);
// A02: Type a word
console.log('\n--- A02: Type a word ---');
await sc(page, 'A02a-clean-prompt');
await page.keyboard.type('h', { delay: 60 });
await page.waitForTimeout(200);
await sc(page, 'A02b-after-h');
await page.keyboard.type('ello', { delay: 60 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'A02c-after-hello');
if (st.text === 'hello')
pass('A02', 'Type a word');
else
fail('A02', 'Type a word', 'text="hello"', `text="${st.text}"`, st, 'A02c-after-hello', jsErrors);
await cleanPrompt(page);
// A03: Type then backspace one char
console.log('\n--- A03: Type then backspace one char ---');
await page.keyboard.type('hello', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'A03a-typed-hello');
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'A03b-after-backspace');
if (st.text === 'hell')
pass('A03', 'Backspace one char');
else
fail('A03', 'Backspace one char', 'text="hell"', `text="${st.text}"`, st, 'A03b-after-backspace', jsErrors);
await cleanPrompt(page);
// A04: Type then backspace ALL chars
console.log('\n--- A04: Type then backspace ALL ---');
await page.keyboard.type('abc', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'A04a-typed-abc');
await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'A04b-after-1-backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'A04c-after-2-backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'A04d-after-3-backspace');
if (!st.visible && st.text === '' && st.flushed === 0)
pass('A04', 'Backspace ALL hides overlay');
else
fail('A04', 'Backspace ALL hides overlay', 'visible=false, text="", flushed=0',
`visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'A04d-after-3-backspace', jsErrors);
// ════════════════════════════════════════════════════════════════
// GROUP B: Tab Switch Basics (5 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP B: Tab Switch Basics ══');
// B05: Type on A, switch to B, check B is clean
console.log('\n--- B05: Type on A, switch to B ---');
await cleanPrompt(page);
await page.keyboard.type('hello world', { delay: 30 });
await page.waitForTimeout(300);
await sc(page, 'B05a-typed-on-A');
await switchTab(page, sB);
st = await getState(page);
await sc(page, 'B05b-on-B');
if (st.text === '' && (st.flushedMap[sA] === 11 || st.flushedMap[sA] > 0))
pass('B05', 'Switch to B clears overlay, A flushed');
else
fail('B05', 'Switch to B clears overlay', 'text="", flushedMap[A]=11',
`text="${st.text}", flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'B05b-on-B', jsErrors);
// B06: Type on A, switch to B, back to A — flushed restored + overlay visible
console.log('\n--- B06: Round-trip flushed restore ---');
await switchTab(page, sA);
await focus(page);
await cleanPrompt(page);
await page.keyboard.type('testing', { delay: 30 });
await page.waitForTimeout(300);
await sc(page, 'B06a-typed-on-A');
await switchTab(page, sB);
await sc(page, 'B06b-on-B');
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, 'B06c-back-on-A');
// After fix: overlay stays VISIBLE with flushed text (no pending), so user
// can see what they typed and backspace into it with visual feedback.
if (st.text === '' && st.flushed === 7 && st.visible)
pass('B06', 'Flushed restored + overlay visible');
else
fail('B06', 'Flushed restored + overlay visible', 'text="", flushed=7, visible=true',
`text="${st.text}", flushed=${st.flushed}, visible=${st.visible}`, st, 'B06c-back-on-A', jsErrors);
// B07: Type on A, switch, back, type SPACE — no text erasure
console.log('\n--- B07: Space after tab switch ---');
await cleanPrompt(page);
await page.keyboard.type('test', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'B07a-typed-test');
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await sc(page, 'B07b-back-on-A');
await page.keyboard.type(' ', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'B07c-after-space');
const b07childText = st.children?.[0]?.text || '';
if (st.text === ' ' && st.flushed === 4 && b07childText.includes('test'))
pass('B07', 'Space after switch — no erasure');
else
fail('B07', 'Space after switch', 'text=" ", flushed=4, child contains "test"',
`text="${st.text}", flushed=${st.flushed}, child="${b07childText}"`, st, 'B07c-after-space', jsErrors);
// B08: Type on A, switch, back, type more chars
console.log('\n--- B08: More chars after round-trip ---');
await cleanPrompt(page);
await page.keyboard.type('fix', { delay: 30 });
await page.waitForTimeout(200);
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await sc(page, 'B08a-back-on-A');
await page.keyboard.type(' bug', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'B08b-after-more-chars');
if (st.text === ' bug' && st.flushed === 3)
pass('B08', 'Type more chars after round-trip');
else
fail('B08', 'Type more after round-trip', 'text=" bug", flushed=3',
`text="${st.text}", flushed=${st.flushed}`, st, 'B08b-after-more-chars', jsErrors);
// B09: Type on A, switch, back, backspace 1 char from flushed
// After fix: overlay stays visible with flushed text, backspace gives instant feedback
console.log('\n--- B09: Backspace into flushed ---');
await cleanPrompt(page);
await page.keyboard.type('hello', { delay: 30 });
await page.waitForTimeout(200);
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await sc(page, 'B09a-back-with-flushed');
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'B09b-after-backspace');
const b09childText = st.children?.[0]?.text || '';
if (st.flushed === 4 && st.visible && b09childText === 'hell')
pass('B09', 'Backspace into flushed with visual feedback');
else
fail('B09', 'Backspace into flushed', 'flushed=4, visible=true, child="hell"',
`flushed=${st.flushed}, visible=${st.visible}, child="${b09childText}"`, st, 'B09b-after-backspace', jsErrors);
// ════════════════════════════════════════════════════════════════
// GROUP C: Tab Switch + Editing (5 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP C: Tab Switch + Editing ══');
// C10: Type, switch, back, backspace 3 + type new text
// After fix: overlay renders correctly using stored flushed text
console.log('\n--- C10: Backspace flushed + retype ---');
await cleanPrompt(page);
await page.keyboard.type('old text', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'C10a-typed');
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
for (let i = 0; i < 3; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'C10b-after-3-backspace');
// After 3 backspaces: flushed "old text" (8) - 3 = "old t" (5)
const c10midChild = st.children?.[0]?.text || '';
if (st.flushed === 5 && c10midChild === 'old t')
pass('C10a', 'Backspace flushed shows correct remaining text');
else
fail('C10a', 'Backspace flushed text', 'flushed=5, child="old t"',
`flushed=${st.flushed}, child="${c10midChild}"`, st, 'C10b-after-3-backspace', jsErrors);
await page.keyboard.type('new', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'C10c-after-retype');
const c10childText = st.children?.[0]?.text || '';
if (st.flushed === 5 && st.text === 'new' && c10childText === 'old tnew')
pass('C10b', 'Retype after flushed backspace renders correctly');
else
fail('C10b', 'Retype after flushed backspace', 'flushed=5, text="new", child="old tnew"',
`flushed=${st.flushed}, text="${st.text}", child="${c10childText}"`, st, 'C10c-after-retype', jsErrors);
// C11: Type, switch, back, delete ALL flushed
console.log('\n--- C11: Delete all flushed ---');
await cleanPrompt(page);
await page.keyboard.type('abcde', { delay: 30 });
await page.waitForTimeout(200);
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'C11a-after-full-backspace');
// Extra backspaces — should be no-op
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'C11b-extra-backspace-noop');
if (!st.visible && st.flushed === 0)
pass('C11', 'Delete all flushed');
else
fail('C11', 'Delete all flushed', 'visible=false, flushed=0',
`visible=${st.visible}, flushed=${st.flushed}`, st, 'C11b-extra-backspace-noop', jsErrors);
// C12: Type, switch, back, delete ALL + type fresh
console.log('\n--- C12: Delete all + type fresh ---');
await cleanPrompt(page);
await page.keyboard.type('remove', { delay: 30 });
await page.waitForTimeout(200);
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(200);
await sc(page, 'C12a-fully-deleted');
await page.keyboard.type('brand new', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'C12b-fresh-text');
if (st.text === 'brand new' && st.flushed === 0 && st.visible)
pass('C12', 'Fresh text after full delete');
else
fail('C12', 'Fresh text after full delete', 'text="brand new", flushed=0, visible=true',
`text="${st.text}", flushed=${st.flushed}, visible=${st.visible}`, st, 'C12b-fresh-text', jsErrors);
// C13: Type on A, switch B, type on B, back to A — no cross-talk
console.log('\n--- C13: No cross-talk between tabs ---');
await cleanPrompt(page);
await page.keyboard.type('alpha', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'C13a-alpha-on-A');
await switchTab(page, sB);
await focus(page);
await waitForPrompt(page, 5000);
await page.keyboard.type('beta', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'C13b-beta-on-B');
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, 'C13c-back-on-A');
if (st.flushedMap[sA] === 5 && st.text === '')
pass('C13', 'No cross-talk between tabs');
else
fail('C13', 'No cross-talk', 'flushedMap[A]=5, text=""',
`flushedMap=${JSON.stringify(st.flushedMap)}, text="${st.text}"`, st, 'C13c-back-on-A', jsErrors);
// C14: Both sessions maintain state across double round-trip
console.log('\n--- C14: Double round-trip state ---');
await cleanPrompt(page);
// Clean B first
await switchTab(page, sB);
await focus(page);
await cleanPrompt(page);
await switchTab(page, sA);
await focus(page);
await cleanPrompt(page);
await page.keyboard.type('aaa', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'C14a-aaa-on-A');
await switchTab(page, sB);
await focus(page);
await page.keyboard.type('bbb', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'C14b-bbb-on-B');
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, 'C14c-back-A');
const c14aFlushed1 = st.flushed;
await page.keyboard.type('+', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'C14d-plus-on-A');
await switchTab(page, sB);
await focus(page);
st = await getState(page);
await sc(page, 'C14e-back-B');
const c14bFlushed = st.flushed;
await page.keyboard.type('+', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'C14f-plus-on-B');
st = await getState(page);
if (c14aFlushed1 >= 3 && c14bFlushed >= 3)
pass('C14', 'Double round-trip state preserved');
else
fail('C14', 'Double round-trip', 'flushedMap[A]>=3, flushedMap[B]>=3',
`A flushed=${c14aFlushed1}, B flushed=${c14bFlushed}`, st, 'C14f-plus-on-B', jsErrors);
// ════════════════════════════════════════════════════════════════
// GROUP D: Enter / Submit (3 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP D: Enter / Submit ══');
// D15: Type then Enter clears everything
console.log('\n--- D15: Enter clears overlay ---');
await switchTab(page, sA);
await focus(page);
await cleanPrompt(page);
await page.keyboard.type('echo test-d15', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'D15a-before-enter');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
await sc(page, 'D15b-after-enter');
await page.waitForTimeout(1000);
st = await getState(page);
await sc(page, 'D15c-settled');
if (!st.visible && st.text === '' && st.flushed === 0)
pass('D15', 'Enter clears overlay');
else
fail('D15', 'Enter clears overlay', 'visible=false, text="", flushed=0',
`visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'D15c-settled', jsErrors);
// D16: Type, switch (flushed), back, Enter
console.log('\n--- D16: Enter after flushed round-trip ---');
await page.waitForTimeout(3000); // Wait for response
await focus(page);
await waitForPrompt(page, 5000);
await page.keyboard.type('flush-test-d16', { delay: 30 });
await page.waitForTimeout(200);
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, 'D16a-flushed-state');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
st = await getState(page);
await sc(page, 'D16b-after-enter');
if (st.flushed === 0 && !st.flushedMap[sA])
pass('D16', 'Enter clears flushed state');
else
fail('D16', 'Enter clears flushed', 'flushed=0, flushedMap[A]=undefined',
`flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'D16b-after-enter', jsErrors);
// D17: Type, Enter, wait for response, type on new prompt
console.log('\n--- D17: Type on new prompt after response ---');
await page.waitForTimeout(4000); // Wait for response
await focus(page);
await waitForPrompt(page, 8000);
await sc(page, 'D17a-before-enter');
// Don't submit — D15/D16 already submitted. Just type on the new prompt.
await page.keyboard.type('second input', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'D17c-second-input');
if (st.text === 'second input' && st.flushed === 0)
pass('D17', 'Type on new prompt after response');
else
fail('D17', 'New prompt typing', 'text="second input", flushed=0',
`text="${st.text}", flushed=${st.flushed}`, st, 'D17c-second-input', jsErrors);
await cleanPrompt(page);
// ════════════════════════════════════════════════════════════════
// GROUP E: Ctrl+C / Cancel (3 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP E: Ctrl+C / Cancel ══');
// E18: Type then Ctrl+C clears overlay
console.log('\n--- E18: Ctrl+C clears overlay ---');
await page.keyboard.type('echo test-e18', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'E18a-before-ctrlc');
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
st = await getState(page);
await sc(page, 'E18b-after-ctrlc');
if (!st.visible && st.text === '' && st.flushed === 0)
pass('E18', 'Ctrl+C clears overlay');
else
fail('E18', 'Ctrl+C clears overlay', 'visible=false, text="", flushed=0',
`visible=${st.visible}, text="${st.text}", flushed=${st.flushed}`, st, 'E18b-after-ctrlc', jsErrors);
// E19: Type, switch (flushed), back, Ctrl+C
console.log('\n--- E19: Ctrl+C after flushed ---');
await focus(page);
await waitForPrompt(page, 5000);
await page.keyboard.type('flush-then-cancel', { delay: 30 });
await page.waitForTimeout(200);
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, 'E19a-flushed-state');
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
st = await getState(page);
await sc(page, 'E19b-after-ctrlc');
if (st.flushed === 0 && !st.flushedMap[sA])
pass('E19', 'Ctrl+C clears flushed state');
else
fail('E19', 'Ctrl+C flushed clear', 'flushed=0, flushedMap[A]=undefined',
`flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'E19b-after-ctrlc', jsErrors);
// E20: Ctrl+C on empty — no crash
console.log('\n--- E20: Ctrl+C on empty ---');
await focus(page);
await waitForPrompt(page, 5000);
const jsErrorsBefore = jsErrors.length;
await sc(page, 'E20a-empty-prompt');
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
st = await getState(page);
await sc(page, 'E20b-after-ctrlc');
if (!st.visible && jsErrors.length === jsErrorsBefore)
pass('E20', 'Ctrl+C on empty — no crash');
else
fail('E20', 'Ctrl+C on empty', 'visible=false, no new JS errors',
`visible=${st.visible}, new errors=${jsErrors.length - jsErrorsBefore}`, st, 'E20b-after-ctrlc', jsErrors);
// ════════════════════════════════════════════════════════════════
// GROUP F: Cursor Position (3 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP F: Cursor Position ══');
// F21: Cursor at end of typed text
console.log('\n--- F21: Cursor at end ---');
await focus(page);
await waitForPrompt(page, 5000);
await page.keyboard.type('cursor', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'F21a-typed-cursor');
if (st.cursor.exists)
pass('F21', 'Cursor exists at end of text');
else
fail('F21', 'Cursor at end', 'cursor.exists=true',
`cursor.exists=${st.cursor.exists}`, st, 'F21a-typed-cursor', jsErrors);
await cleanPrompt(page);
// F22: Cursor after backspace moves left
console.log('\n--- F22: Cursor moves on backspace ---');
await page.keyboard.type('abcde', { delay: 30 });
await page.waitForTimeout(300);
let st1 = await getState(page);
await sc(page, 'F22a-five-chars');
const cursorLeftBefore = st1.cursor.left;
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'F22b-after-backspace');
if (st.cursor.exists && st.cursor.left < cursorLeftBefore)
pass('F22', 'Cursor moves left on backspace');
else
fail('F22', 'Cursor moves left', `cursor.left < ${cursorLeftBefore}`,
`cursor.left=${st.cursor.left}`, st, 'F22b-after-backspace', jsErrors);
await cleanPrompt(page);
// F23: Cursor after tab switch + retype
console.log('\n--- F23: Cursor after flushed + pending ---');
await page.keyboard.type('hi', { delay: 30 });
await page.waitForTimeout(200);
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.keyboard.type('!!', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'F23a-flushed-plus-pending');
// Cursor should exist — that's the key assertion
if (st.cursor.exists)
pass('F23', 'Cursor correct after flushed + pending');
else
fail('F23', 'Cursor after flushed+pending', 'cursor.exists=true',
`cursor.exists=${st.cursor.exists}`, st, 'F23a-flushed-plus-pending', jsErrors);
await cleanPrompt(page);
// ════════════════════════════════════════════════════════════════
// GROUP G: Visual Consistency (4 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP G: Visual Consistency ══');
// G24: Overlay position stable between keystrokes
console.log('\n--- G24: Position stable between keystrokes ---');
await page.keyboard.type('a', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'G24a-first-char');
const g24posA = st.children?.[0]?.left;
const g24topA = st.children?.[0]?.top;
await page.keyboard.type('b', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'G24b-second-char');
const g24posB = st.children?.[0]?.left;
const g24topB = st.children?.[0]?.top;
await page.keyboard.type('c', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'G24c-third-char');
const g24posC = st.children?.[0]?.left;
const g24topC = st.children?.[0]?.top;
if (g24posA === g24posB && g24posB === g24posC && g24topA === g24topB && g24topB === g24topC)
pass('G24', 'Overlay position stable between keystrokes');
else
fail('G24', 'Position stable', `all same left/top`,
`left: ${g24posA}, ${g24posB}, ${g24posC}; top: ${g24topA}, ${g24topB}, ${g24topC}`, st, 'G24c-third-char', jsErrors);
await cleanPrompt(page);
// G25: Overlay position stable across tab switch
console.log('\n--- G25: Position stable across tab switch ---');
await page.keyboard.type('pos test', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'G25a-before-switch');
const g25leftBefore = st.children?.[0]?.left;
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.keyboard.type(' more', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'G25b-after-switch-type');
const g25leftAfter = st.children?.[0]?.left;
if (g25leftBefore === g25leftAfter)
pass('G25', 'Position stable across tab switch');
else
fail('G25', 'Position across switch', `left unchanged`,
`before=${g25leftBefore}, after=${g25leftAfter}`, st, 'G25b-after-switch-type', jsErrors);
await cleanPrompt(page);
// G26: Overlay background covers full width
console.log('\n--- G26: Overlay background width ---');
await page.keyboard.type('x', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'G26a-overlay-width');
const childWidth = st.children?.[0]?.width;
if (childWidth && parseFloat(childWidth) > 0)
pass('G26', 'Overlay has non-zero width');
else
fail('G26', 'Overlay width', 'width > 0',
`width="${childWidth}"`, st, 'G26a-overlay-width', jsErrors);
await cleanPrompt(page);
// G27: No JS errors across all tests
console.log('\n--- G27: No JS errors ---');
await sc(page, 'G27-final-state');
if (jsErrors.length === 0)
pass('G27', 'No JS errors');
else
fail('G27', 'No JS errors', '0 errors',
`${jsErrors.length} errors: ${jsErrors.slice(0, 3).join('; ')}`, null, 'G27-final-state', jsErrors);
// ════════════════════════════════════════════════════════════════
// GROUP H: Edge Cases (3 tests)
// ════════════════════════════════════════════════════════════════
console.log('\n══ GROUP H: Edge Cases ══');
// H28: Paste multi-char text
console.log('\n--- H28: Paste simulation ---');
await focus(page);
await waitForPrompt(page, 5000);
await page.evaluate(() => {
window.app?._localEchoOverlay?.appendText('pasted');
});
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'H28a-after-paste');
if (st.text === 'pasted' && st.visible)
pass('H28', 'Paste appends text');
else
fail('H28', 'Paste appends text', 'text="pasted", visible=true',
`text="${st.text}", visible=${st.visible}`, st, 'H28a-after-paste', jsErrors);
await cleanPrompt(page);
// H29: Rapid type-backspace-type
console.log('\n--- H29: Rapid type-backspace-type ---');
await page.keyboard.type('hello', { delay: 15 });
await page.waitForTimeout(100);
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
await page.keyboard.type('p me', { delay: 15 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'H29a-after-rapid');
if (st.text === 'help me')
pass('H29', 'Rapid type-backspace-type');
else
fail('H29', 'Rapid editing', 'text="help me"',
`text="${st.text}"`, st, 'H29a-after-rapid', jsErrors);
await cleanPrompt(page);
// H30: Page reload clears all state
console.log('\n--- H30: Page reload clears state ---');
await page.keyboard.type('will be lost', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, 'H30a-before-reload');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(3000);
// Re-enable local echo after reload
await page.evaluate(() => {
window.app?._updateLocalEchoState?.();
});
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'H30b-after-reload');
if (st.text === '' && st.flushed === 0 && Object.keys(st.flushedMap).length === 0)
pass('H30', 'Reload clears all state');
else
fail('H30', 'Reload clears state', 'text="", flushed=0, flushedMap empty',
`text="${st.text}", flushed=${st.flushed}, flushedMap=${JSON.stringify(st.flushedMap)}`, st, 'H30b-after-reload', jsErrors);
// ══════════════════════════════════════════════════════════════
// GROUP I: Long Text / Line Wrapping
// ══════════════════════════════════════════════════════════════
console.log('\n══ GROUP I: Long Text / Line Wrapping ══');
// Need a prompt to work with after H30 reload
// Re-select session A and wait for prompt
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sA);
await page.waitForTimeout(2000);
await page.evaluate(() => {
2026-02-26 16:44:34 +01:00
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
2026-02-26 16:44:34 +01:00
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
await page.waitForTimeout(300);
await focus(page);
await waitForPrompt(page);
// Get terminal columns to know the wrap point
const termCols = await page.evaluate(() => window.app?.terminal?.cols || 80);
// Prompt uses "❯ " (2 chars), so first line has (cols - 2) available chars
const firstLineCols = termCols - 2;
// I31: Type text that fills exactly first line (no wrap)
console.log('\n--- I31: Type text that fills first line ---');
await cleanPrompt(page);
await focus(page);
await waitForPrompt(page);
const fillText = 'A'.repeat(firstLineCols);
await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), fillText);
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'I31a-full-first-line');
if (st.visible && st.text === fillText && st.children.length >= 1) {
pass('I31', 'Full first line renders correctly');
} else {
fail('I31', 'Full first line', `visible=true, text=${fillText.length} chars, children>=1`,
`visible=${st.visible}, text=${st.text.length} chars, children=${st.children.length}`, st, 'I31a-full-first-line', jsErrors);
}
await cleanPrompt(page);
// I32: Type text that wraps to second line
console.log('\n--- I32: Type text that wraps to second line ---');
await focus(page);
await waitForPrompt(page);
const wrapText = 'B'.repeat(firstLineCols + 5); // 5 chars on second line
await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText);
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'I32a-wrapped-to-line-2');
// Should have 2 line divs + 1 cursor span = 3 children
const lineChildren = st.children.filter(c => c.spanCount > 0);
if (st.visible && st.text === wrapText && lineChildren.length === 2) {
pass('I32', 'Text wraps to second line correctly');
} else {
fail('I32', 'Text wraps', `visible=true, text=${wrapText.length} chars, 2 line divs`,
`visible=${st.visible}, text=${st.text.length} chars, line divs=${lineChildren.length}`, st, 'I32a-wrapped-to-line-2', jsErrors);
}
// Check cursor is on the second line
if (st.cursor.exists && st.cursor.top > 0) {
pass('I32c', 'Cursor on second line');
} else {
fail('I32c', 'Cursor position', 'cursor.top > 0 (second line)',
`cursor.top=${st.cursor.top}`, st, 'I32a-wrapped-to-line-2', jsErrors);
}
await cleanPrompt(page);
// I33: Type wrapping text, backspace back to first line
console.log('\n--- I33: Wrap then backspace to first line ---');
await focus(page);
await waitForPrompt(page);
const wrapText2 = 'C'.repeat(firstLineCols + 3);
await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText2);
await page.waitForTimeout(200);
await sc(page, 'I33a-wrapped');
// Backspace the 3 chars on second line
for (let i = 0; i < 3; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(30);
}
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'I33b-back-to-one-line');
const lineChildrenAfter = st.children.filter(c => c.spanCount > 0);
if (st.visible && st.text.length === firstLineCols && lineChildrenAfter.length === 1) {
pass('I33', 'Backspace from second line to first');
} else {
fail('I33', 'Backspace to first line', `text=${firstLineCols} chars, 1 line div`,
`text=${st.text.length} chars, line divs=${lineChildrenAfter.length}`, st, 'I33b-back-to-one-line', jsErrors);
}
await cleanPrompt(page);
// I34: Wrapping text survives tab switch
console.log('\n--- I34: Wrapping text after tab switch ---');
await focus(page);
await waitForPrompt(page);
const wrapLen34 = firstLineCols + 8;
const wrapText3 = 'D'.repeat(wrapLen34);
// Use appendText for instant injection (avoids keyboard timing issues)
await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), wrapText3);
await page.waitForTimeout(200);
// Verify text is buffered before switching
st = await getState(page);
await sc(page, 'I34a-wrapped-before-switch');
console.log(` buffered: text=${st.text.length} chars, visible=${st.visible}`);
// Switch to B
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sB);
await page.waitForTimeout(1500);
// Switch back to A
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), sA);
await page.waitForTimeout(2500);
st = await getState(page);
await sc(page, 'I34b-after-round-trip');
// After round-trip, flushed should contain the wrapping text
if (st.flushed >= wrapLen34) {
pass('I34a', 'Flushed wrapping text preserved');
} else {
fail('I34a', 'Flushed wrap text', `flushed>=${wrapLen34}`,
`flushed=${st.flushed}`, st, 'I34b-after-round-trip', jsErrors);
}
// Overlay should be visible with flushed text
if (st.visible && st.children.length >= 2) {
pass('I34b', 'Flushed wrapping text renders multi-line');
} else {
fail('I34b', 'Flushed wrap render', 'visible=true, children>=2',
`visible=${st.visible}, children=${st.children.length}`, st, 'I34b-after-round-trip', jsErrors);
}
// Type more to extend the wrap
await focus(page);
await page.keyboard.type('EE', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
await sc(page, 'I34c-extended-wrap');
if (st.text === 'EE' && st.flushed >= wrapLen34) {
pass('I34c', 'Extended wrapping after tab switch');
} else {
fail('I34c', 'Extended wrap', `text="EE", flushed>=${wrapLen34}`,
`text="${st.text}", flushed=${st.flushed}`, st, 'I34c-extended-wrap', jsErrors);
}
await cleanPrompt(page);
// I35: Very long text (3+ lines)
console.log('\n--- I35: Very long text (3+ lines) ---');
await focus(page);
await waitForPrompt(page);
const longText = 'F'.repeat(firstLineCols + termCols + 5); // first line + full second + 5 on third
await page.evaluate((text) => window.app._localEchoOverlay.appendText(text), longText);
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, 'I35a-three-lines');
const longLineChildren = st.children.filter(c => c.spanCount > 0);
if (st.visible && st.text === longText && longLineChildren.length === 3) {
pass('I35', 'Very long text renders 3 lines');
} else {
fail('I35', '3-line text', `visible=true, text=${longText.length} chars, 3 line divs`,
`visible=${st.visible}, text=${st.text.length} chars, line divs=${longLineChildren.length}`, st, 'I35a-three-lines', jsErrors);
}
await cleanPrompt(page);
// ════════════════════════════════════════════════════════════════
// SUMMARY
// ════════════════════════════════════════════════════════════════
console.log('\n════════════════════════════════════════');
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
console.log(` ${passed} passed, ${failed} failed out of ${results.length} assertions`);
console.log(` Screenshots: ${SCREENSHOT_DIR}/`);
if (failed > 0) {
console.log('\n FAILURES:');
for (const r of results.filter(r => !r.ok)) {
console.log(` ❌ ${r.id}: ${r.name}`);
}
}
if (jsErrors.length > 0) {
console.log(`\n JS Errors (${jsErrors.length}):`);
for (const e of jsErrors.slice(0, 5)) console.log(` ⚠️ ${e}`);
}
console.log('════════════════════════════════════════');
} finally {
// Write error report
writeErrorReport();
console.log(`\n Error report: ${SCREENSHOT_DIR}/error-report.md`);
await browser.close();
console.log('\nCleaning up sessions...');
await deleteSession(sA);
await deleteSession(sB);
console.log('Done.\n');
}
}
main().catch(err => { console.error('Test failed:', err); process.exit(1); });