mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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
399 lines
16 KiB
JavaScript
399 lines
16 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|