mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: remove dead test and script files
Dead-code sweep via knip. All files below have zero importers and were leftovers from the local-echo overlay exploration or duplicated by files under test/mocks/. Deleted: - test/respawn-test-utils.ts (728-line duplicate of test/mocks/*) - test/input-echo-test.mjs - test/local-echo-*.mjs (7 files) - test/manual/*.mjs (10 files; dir removed) - scripts/remotion/components/TerminalScreen.tsx (unused Remotion demo) Also cleaned stale JSDoc references to the removed respawn-test-utils.ts in test/mocks/mock-session.ts and test/mocks/test-helpers.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,117 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useCurrentFrame } from 'remotion';
|
||||
import { colors, fonts } from '../lib/theme';
|
||||
|
||||
type OverlayChar = {
|
||||
char: string;
|
||||
confirmed: boolean;
|
||||
};
|
||||
|
||||
type TerminalScreenProps = {
|
||||
typed: string;
|
||||
cursorVisible: boolean;
|
||||
overlayChars?: OverlayChar[];
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a terminal area styled exactly like the real Codeman xterm.js terminal.
|
||||
* Background #0d0d0d, Fira Code font, block cursor, Claude Code prompt.
|
||||
*/
|
||||
export const TerminalScreen: React.FC<TerminalScreenProps> = ({
|
||||
typed,
|
||||
cursorVisible,
|
||||
overlayChars,
|
||||
fontSize = 28,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Block cursor — solid, no blink (matches Codeman's cursorBlink: false)
|
||||
const cursorOn = cursorVisible;
|
||||
// But add a subtle blink for video clarity so viewers notice it
|
||||
const cursorOpacity = cursorOn ? (Math.floor(frame / 20) % 2 === 0 ? 0.9 : 0.6) : 0;
|
||||
|
||||
const lineHeight = Math.round(fontSize * 1.35);
|
||||
const charWidth = fontSize * 0.6;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#0d0d0d',
|
||||
position: 'relative',
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Previous terminal output lines (fake history for realism) */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
fontSize: fontSize * 0.65,
|
||||
lineHeight: `${Math.round(fontSize * 0.65 * 1.4)}px`,
|
||||
color: '#495057',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: '#339af0' }}>❯</span>
|
||||
<span style={{ color: '#495057' }}> claude --dangerously-skip-permissions</span>
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a', marginTop: 4 }}>
|
||||
╭────────────────────────────────────╮
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a' }}>
|
||||
│ Claude Code session active │
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a' }}>
|
||||
╰────────────────────────────────────╯
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active prompt line — this is where the typing happens */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
{/* Prompt character */}
|
||||
<span style={{ color: '#339af0', fontWeight: 700, marginRight: charWidth * 0.8 }}>❯</span>
|
||||
|
||||
{/* Typed text */}
|
||||
{overlayChars ? (
|
||||
// Zerolag mode: show overlay chars with confirmed/unconfirmed color
|
||||
overlayChars.map((oc, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: oc.confirmed ? '#e0e0e0' : '#7a7a7a',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{oc.char}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: '#e0e0e0', letterSpacing: '0.5px' }}>{typed}</span>
|
||||
)}
|
||||
|
||||
{/* Block cursor */}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: charWidth,
|
||||
height: lineHeight * 0.85,
|
||||
background: `rgba(224, 224, 224, ${cursorOpacity})`,
|
||||
verticalAlign: 'text-bottom',
|
||||
marginLeft: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* Input echo test: Verifies that typing in the web UI doesn't produce
|
||||
* "visible below" artifacts (text mirrored onto Ink's status bar rows).
|
||||
*
|
||||
* Test approach:
|
||||
* 1. Creates a SHELL session (bash, not Claude) to avoid Ink complexity
|
||||
* 2. Types various input patterns and captures terminal state
|
||||
* 3. Verifies echo appears at correct cursor position, not duplicated
|
||||
*
|
||||
* Usage: node test/input-echo-test.mjs [--live]
|
||||
* --live Use existing sessions instead of creating new ones
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const PORT = 3000;
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
|
||||
async function createShellSession() {
|
||||
const resp = await fetch(`${BASE}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'shell', name: 'input-test' })
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Failed to create session: ${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 waitForSessionReady(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;
|
||||
}
|
||||
|
||||
async function getTerminalContent(page) {
|
||||
return page.evaluate(() => {
|
||||
const app = window.app;
|
||||
if (!app?.terminal) return null;
|
||||
const buf = app.terminal.buffer.active;
|
||||
const lines = [];
|
||||
for (let i = 0; i <= buf.baseY + buf.viewportY + app.terminal.rows; i++) {
|
||||
const line = buf.getLine(i);
|
||||
if (line) lines.push(line.translateToString(true));
|
||||
}
|
||||
return {
|
||||
lines,
|
||||
cursorX: buf.cursorX,
|
||||
cursorY: buf.cursorY,
|
||||
rows: app.terminal.rows,
|
||||
cols: app.terminal.cols,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const isLive = process.argv.includes('--live');
|
||||
let sessionId = null;
|
||||
|
||||
console.log('=== Input Echo Test ===\n');
|
||||
|
||||
// Create a shell session for testing
|
||||
if (!isLive) {
|
||||
console.log('Creating shell session...');
|
||||
sessionId = await createShellSession();
|
||||
console.log(`Session created: ${sessionId}`);
|
||||
|
||||
const ready = await waitForSessionReady(sessionId);
|
||||
if (!ready) {
|
||||
console.error('Session failed to become ready');
|
||||
await deleteSession(sessionId);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Session ready\n');
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--window-size=1280,800']
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Collect console output for debugging
|
||||
const consoleLogs = [];
|
||||
page.on('console', msg => consoleLogs.push(`[${msg.type()}] ${msg.text()}`));
|
||||
|
||||
try {
|
||||
console.log('Opening Codeman...');
|
||||
await page.goto(BASE);
|
||||
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Switch to our test session if not live
|
||||
if (sessionId) {
|
||||
// Click the test session tab
|
||||
const tabClicked = await page.evaluate((id) => {
|
||||
const tabs = document.querySelectorAll('.session-tab');
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.sessionId === id) {
|
||||
tab.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, sessionId);
|
||||
if (tabClicked) {
|
||||
await page.waitForTimeout(1000);
|
||||
console.log('Switched to test session');
|
||||
}
|
||||
}
|
||||
|
||||
// Focus the terminal
|
||||
await page.click('.xterm-screen');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Take before screenshot
|
||||
await page.screenshot({ path: '/tmp/input-test-before.png' });
|
||||
console.log('Screenshot: /tmp/input-test-before.png (before typing)');
|
||||
|
||||
// Get terminal state before typing
|
||||
const beforeState = await getTerminalContent(page);
|
||||
const beforeCursorY = beforeState?.cursorY ?? -1;
|
||||
console.log(`Before: cursor at row ${beforeCursorY}, col ${beforeState?.cursorX}`);
|
||||
|
||||
// TEST 1: Type a short string
|
||||
console.log('\n--- Test 1: Short string ---');
|
||||
const testStr = 'echo hello-world-test-123';
|
||||
await page.keyboard.type(testStr, { delay: 30 });
|
||||
await page.waitForTimeout(600); // Wait for PTY echo round-trip
|
||||
|
||||
const afterShort = await getTerminalContent(page);
|
||||
await page.screenshot({ path: '/tmp/input-test-short.png' });
|
||||
console.log('Screenshot: /tmp/input-test-short.png');
|
||||
|
||||
// Check: cursor should still be on same row (or +1 for wrap)
|
||||
// and text should appear at/near the cursor position
|
||||
const cursorRowAfter = afterShort?.cursorY ?? -1;
|
||||
console.log(`After: cursor at row ${cursorRowAfter}, col ${afterShort?.cursorX}`);
|
||||
|
||||
// The echo should be on the cursor's row, not below
|
||||
const echoLine = afterShort?.lines?.[afterShort.cursorY + (afterShort.lines.length - afterShort.rows)] ?? '';
|
||||
console.log(`Echo line: "${echoLine.trim()}"`);
|
||||
if (echoLine.includes(testStr)) {
|
||||
console.log('PASS: Echo found at cursor row');
|
||||
} else {
|
||||
console.log('INFO: Echo may be on a different row (check screenshots)');
|
||||
}
|
||||
|
||||
// TEST 2: Press Enter and check response
|
||||
console.log('\n--- Test 2: Enter key ---');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const afterEnter = await getTerminalContent(page);
|
||||
await page.screenshot({ path: '/tmp/input-test-enter.png' });
|
||||
console.log('Screenshot: /tmp/input-test-enter.png');
|
||||
|
||||
// Check for "hello-world-test-123" in the output
|
||||
const allText = afterEnter?.lines?.join('\n') ?? '';
|
||||
if (allText.includes('hello-world-test-123')) {
|
||||
console.log('PASS: Command output visible');
|
||||
} else {
|
||||
console.log('WARN: Command output not found');
|
||||
}
|
||||
|
||||
// TEST 3: Type a LONG string that approaches right edge
|
||||
console.log('\n--- Test 3: Long string (near right edge) ---');
|
||||
const longStr = 'echo ' + 'A'.repeat(afterShort?.cols ? afterShort.cols - 10 : 150);
|
||||
await page.keyboard.type(longStr, { delay: 10 });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const afterLong = await getTerminalContent(page);
|
||||
await page.screenshot({ path: '/tmp/input-test-long.png' });
|
||||
console.log('Screenshot: /tmp/input-test-long.png');
|
||||
console.log(`After long: cursor at row ${afterLong?.cursorY}, col ${afterLong?.cursorX}`);
|
||||
|
||||
// Check that no text leaked below the cursor row
|
||||
const cursorAbsRow = (afterLong?.lines?.length ?? 0) - (afterLong?.rows ?? 0) + (afterLong?.cursorY ?? 0);
|
||||
const rowsBelow = afterLong?.lines?.slice(cursorAbsRow + 1) ?? [];
|
||||
const leakedText = rowsBelow.filter(l => l.trim().length > 0 && l.includes('AAA'));
|
||||
if (leakedText.length === 0) {
|
||||
console.log('PASS: No text leaked below cursor row');
|
||||
} else {
|
||||
console.log(`FAIL: Text leaked below cursor: ${leakedText.map(l => `"${l.trim()}"`).join(', ')}`);
|
||||
}
|
||||
|
||||
// TEST 4: Rapid typing (paste-like)
|
||||
console.log('\n--- Test 4: Rapid input (paste simulation) ---');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(500);
|
||||
// Type rapidly (no delay between chars)
|
||||
await page.keyboard.type('echo rapid-test-no-delay-abcdefghij', { delay: 0 });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.screenshot({ path: '/tmp/input-test-rapid.png' });
|
||||
console.log('Screenshot: /tmp/input-test-rapid.png');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const afterRapid = await getTerminalContent(page);
|
||||
const rapidText = afterRapid?.lines?.join('\n') ?? '';
|
||||
if (rapidText.includes('rapid-test-no-delay-abcdefghij')) {
|
||||
console.log('PASS: Rapid input command output visible');
|
||||
} else {
|
||||
console.log('WARN: Rapid input output not found (check screenshots)');
|
||||
}
|
||||
|
||||
// Final screenshot
|
||||
await page.screenshot({ path: '/tmp/input-test-final.png' });
|
||||
console.log('\nScreenshot: /tmp/input-test-final.png (final state)');
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Test Complete ===');
|
||||
console.log('Check screenshots in /tmp/input-test-*.png');
|
||||
console.log('Key check: no text should appear below the active cursor row');
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (sessionId && !isLive) {
|
||||
console.log(`\nCleaning up session ${sessionId}...`);
|
||||
await deleteSession(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,590 +0,0 @@
|
||||
/**
|
||||
* 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('codeman-app-settings') || '{}');
|
||||
s.localEchoEnabled = true;
|
||||
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');
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 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); });
|
||||
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* 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('codeman-app-settings') || '{}');
|
||||
s.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-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); });
|
||||
@@ -1,533 +0,0 @@
|
||||
/**
|
||||
* 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('codeman-app-settings') || '{}');
|
||||
s.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-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('codeman_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); });
|
||||
@@ -1,717 +0,0 @@
|
||||
/**
|
||||
* 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 Codeman...');
|
||||
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('codeman-app-settings') || '{}');
|
||||
settings.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-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);
|
||||
});
|
||||
@@ -1,398 +0,0 @@
|
||||
/**
|
||||
* 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('codeman-app-settings') || '{}');
|
||||
s.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-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);
|
||||
});
|
||||
@@ -1,516 +0,0 @@
|
||||
/**
|
||||
* Local Echo Overlay — Tab Switch Bug Reproduction Test
|
||||
*
|
||||
* Reproduces the specific bug: switch tabs, switch back, type a space →
|
||||
* previous input disappears, cursor moves to start.
|
||||
*
|
||||
* Port: 3000 (runs against live dev server)
|
||||
*
|
||||
* Usage: node test/local-echo-tabswitch-test.mjs
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'fs';
|
||||
|
||||
const PORT = 3000;
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
const SCREENSHOT_DIR = '/tmp/local-echo-tabswitch';
|
||||
const results = [];
|
||||
const jsErrors = [];
|
||||
|
||||
function pass(name) { results.push({ name, ok: true }); console.log(` ✓ PASS: ${name}`); }
|
||||
function fail(name, detail) { results.push({ name, ok: false, detail }); console.log(` ✗ FAIL: ${name} — ${detail}`); }
|
||||
|
||||
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 = 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, 200));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getOverlayState(page) {
|
||||
return page.evaluate(() => {
|
||||
const app = window.app;
|
||||
if (!app) return null;
|
||||
const overlay = app._localEchoOverlay;
|
||||
const overlayEl = overlay?.overlay;
|
||||
|
||||
// Get displayed text from overlay DOM children
|
||||
let displayedText = '';
|
||||
if (overlayEl) {
|
||||
for (const child of overlayEl.children) {
|
||||
// Skip cursor element (no inner spans with text or single span with no text)
|
||||
const spans = child.querySelectorAll('span');
|
||||
const text = Array.from(spans).map(s => s.textContent).join('');
|
||||
displayedText += text;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
overlayVisible: overlayEl ? overlayEl.style.display !== 'none' : false,
|
||||
pendingText: overlay?.pendingText || '',
|
||||
flushedOffset: overlay?._flushedOffset || 0,
|
||||
flushedText: overlay?._flushedText || '',
|
||||
displayedText: displayedText.replace(/\u00a0/g, ' '), // normalize nbsp
|
||||
localEchoEnabled: app._localEchoEnabled,
|
||||
activeSessionId: app.activeSessionId,
|
||||
flushedOffsetsMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
|
||||
flushedTextsMap: app._flushedTexts ? Object.fromEntries(app._flushedTexts) : {},
|
||||
lastPromptPos: overlay?._lastPromptPos || null,
|
||||
overlayChildCount: overlayEl?.children?.length || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: false });
|
||||
console.log(` 📸 ${path}`);
|
||||
}
|
||||
|
||||
async function clickTab(page, sessionId) {
|
||||
await page.evaluate((id) => {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (tab) tab.click();
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
async function focusTerminal(page) {
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.xterm-helper-textarea')?.focus();
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
|
||||
|
||||
console.log('=== Local Echo Tab Switch Bug Test ===\n');
|
||||
|
||||
// Create two shell sessions
|
||||
console.log('Creating test sessions...');
|
||||
const sessionA = await createSession('ts-test-A');
|
||||
const sessionB = await createSession('ts-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');
|
||||
|
||||
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',
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on('console', msg => { if (msg.type() === 'error') jsErrors.push(msg.text()); });
|
||||
|
||||
// Install instrumentation to trace flushed state clearing
|
||||
await page.exposeFunction('__traceFlushedClear', (source, data) => {
|
||||
console.log(` 🔍 TRACE: flushed state cleared from "${source}" — data=${data}`);
|
||||
});
|
||||
|
||||
try {
|
||||
// ── SETUP ──
|
||||
console.log('Opening Codeman...');
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Enable local echo
|
||||
console.log('Enabling local echo...');
|
||||
await page.evaluate(() => {
|
||||
const key = window.app?.getSettingsStorageKey?.() || 'codeman-app-settings';
|
||||
const settings = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
settings.localEchoEnabled = true;
|
||||
localStorage.setItem(key, JSON.stringify(settings));
|
||||
if (window.app?._cachedAppSettings) window.app._cachedAppSettings.localEchoEnabled = true;
|
||||
window.app?._updateLocalEchoState?.();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Install tracing hooks inside the page to catch when flushed state is cleared
|
||||
await page.evaluate(() => {
|
||||
const app = window.app;
|
||||
if (!app) return;
|
||||
|
||||
// Patch the onData handler to trace control char clearing
|
||||
// We can't directly patch the inner function, but we can watch the Maps
|
||||
const origDelete = Map.prototype.delete;
|
||||
const flushedOffsets = app._flushedOffsets;
|
||||
const flushedTexts = app._flushedTexts;
|
||||
|
||||
// Trace overlay.clear()
|
||||
if (app._localEchoOverlay) {
|
||||
const origClear = app._localEchoOverlay.clear.bind(app._localEchoOverlay);
|
||||
app._localEchoOverlay.clear = function() {
|
||||
const stack = new Error().stack;
|
||||
// Extract caller info
|
||||
const callerLine = stack.split('\n')[2]?.trim() || 'unknown';
|
||||
console.warn(`[TRACE] overlay.clear() called from: ${callerLine}`);
|
||||
window.__localEchoClearLog = window.__localEchoClearLog || [];
|
||||
window.__localEchoClearLog.push({
|
||||
time: Date.now(),
|
||||
source: 'overlay.clear()',
|
||||
caller: callerLine,
|
||||
hadFlushed: this._flushedOffset > 0,
|
||||
hadPending: this.pendingText.length > 0,
|
||||
});
|
||||
return origClear();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Switch to session A
|
||||
console.log('Switching to Session A...');
|
||||
await clickTab(page, sessionA);
|
||||
await page.waitForTimeout(2000);
|
||||
await focusTerminal(page);
|
||||
|
||||
let state = await getOverlayState(page);
|
||||
console.log(` Local echo enabled: ${state?.localEchoEnabled}`);
|
||||
if (!state?.localEchoEnabled) {
|
||||
fail('Local echo not enabled', `localEchoEnabled=${state?.localEchoEnabled}`);
|
||||
return;
|
||||
}
|
||||
pass('Local echo enabled');
|
||||
|
||||
// ── STEP 1: Type text in Session A ──
|
||||
console.log('\n--- Step 1: Type "hello world" in Session A ---');
|
||||
await page.keyboard.type('hello world', { delay: 50 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '01-typed-in-A');
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` overlayVisible: ${state?.overlayVisible}`);
|
||||
console.log(` displayedText: "${state?.displayedText}"`);
|
||||
|
||||
if (state?.pendingText === 'hello world') {
|
||||
pass('Text typed in Session A');
|
||||
} else {
|
||||
fail('Text in Session A', `expected "hello world", got "${state?.pendingText}"`);
|
||||
}
|
||||
|
||||
// ── STEP 2: Switch to Session B ──
|
||||
console.log('\n--- Step 2: Switch to Session B ---');
|
||||
// Clear trace log
|
||||
await page.evaluate(() => { window.__localEchoClearLog = []; });
|
||||
|
||||
await clickTab(page, sessionB);
|
||||
await page.waitForTimeout(2500);
|
||||
await focusTerminal(page);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '02-switched-to-B');
|
||||
console.log(` activeSession: ${state?.activeSessionId}`);
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
|
||||
console.log(` flushedTextsMap: ${JSON.stringify(state?.flushedTextsMap)}`);
|
||||
|
||||
if (state?.activeSessionId === sessionB) {
|
||||
pass('Switched to Session B');
|
||||
} else {
|
||||
fail('Tab switch to B', `activeSession=${state?.activeSessionId}`);
|
||||
}
|
||||
|
||||
// Check that A's text was stored in flushed maps
|
||||
const flushedForA = state?.flushedOffsetsMap?.[sessionA];
|
||||
if (flushedForA === 11) { // "hello world" = 11 chars
|
||||
pass(`Session A text flushed (${flushedForA} chars)`);
|
||||
} else {
|
||||
fail('Flushed offset for A', `expected 11, got ${flushedForA}`);
|
||||
}
|
||||
|
||||
// ── STEP 3: Type in Session B ──
|
||||
console.log('\n--- Step 3: Type "typing in B" in Session B ---');
|
||||
await page.keyboard.type('typing in B', { delay: 50 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '03-typed-in-B');
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
|
||||
|
||||
if (state?.pendingText === 'typing in B') {
|
||||
pass('Text typed in Session B');
|
||||
} else {
|
||||
fail('Text in Session B', `expected "typing in B", got "${state?.pendingText}"`);
|
||||
}
|
||||
|
||||
// Verify A's flushed state is still in the Maps
|
||||
if (state?.flushedOffsetsMap?.[sessionA] === 11) {
|
||||
pass('Session A flushed state preserved while on B');
|
||||
} else {
|
||||
fail('A flushed state while on B', `expected 11, got ${state?.flushedOffsetsMap?.[sessionA]}`);
|
||||
}
|
||||
|
||||
// ── STEP 4: Switch back to Session A ──
|
||||
console.log('\n--- Step 4: Switch back to Session A ---');
|
||||
await page.evaluate(() => { window.__localEchoClearLog = []; });
|
||||
|
||||
await clickTab(page, sessionA);
|
||||
await page.waitForTimeout(3000); // Wait for full buffer load
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '04-back-to-A-before-typing');
|
||||
console.log(` activeSession: ${state?.activeSessionId}`);
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` flushedText: "${state?.flushedText}"`);
|
||||
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
|
||||
console.log(` overlayVisible: ${state?.overlayVisible}`);
|
||||
console.log(` lastPromptPos: ${JSON.stringify(state?.lastPromptPos)}`);
|
||||
console.log(` displayedText: "${state?.displayedText}"`);
|
||||
|
||||
// Check trace log
|
||||
const clearLog = await page.evaluate(() => window.__localEchoClearLog || []);
|
||||
console.log(` Clear log entries: ${clearLog.length}`);
|
||||
for (const entry of clearLog) {
|
||||
console.log(` - ${entry.source} @ ${entry.caller} (hadFlushed=${entry.hadFlushed}, hadPending=${entry.hadPending})`);
|
||||
}
|
||||
|
||||
if (state?.activeSessionId === sessionA) {
|
||||
pass('Switched back to Session A');
|
||||
} else {
|
||||
fail('Tab switch back to A', `activeSession=${state?.activeSessionId}`);
|
||||
}
|
||||
|
||||
// KEY CHECK: flushed state should be restored
|
||||
if (state?.flushedOffset === 11) {
|
||||
pass('Flushed offset restored on overlay');
|
||||
} else {
|
||||
fail('Flushed offset on overlay', `expected 11, got ${state?.flushedOffset}`);
|
||||
}
|
||||
|
||||
if (state?.flushedText === 'hello world') {
|
||||
pass('Flushed text restored on overlay');
|
||||
} else {
|
||||
fail('Flushed text on overlay', `expected "hello world", got "${state?.flushedText}"`);
|
||||
}
|
||||
|
||||
// ── STEP 5: THE BUG — Type a space after switching back ──
|
||||
console.log('\n--- Step 5: Type a SPACE after returning to Session A ---');
|
||||
await focusTerminal(page);
|
||||
|
||||
// Record state RIGHT BEFORE typing
|
||||
const preTypeState = await getOverlayState(page);
|
||||
console.log(` PRE-TYPE state:`);
|
||||
console.log(` pendingText: "${preTypeState?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${preTypeState?.flushedOffset}`);
|
||||
console.log(` flushedText: "${preTypeState?.flushedText}"`);
|
||||
console.log(` overlayVisible: ${preTypeState?.overlayVisible}`);
|
||||
console.log(` localEchoEnabled: ${preTypeState?.localEchoEnabled}`);
|
||||
|
||||
await page.keyboard.type(' ', { delay: 0 }); // Type single space
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '05-typed-space-after-return');
|
||||
console.log(` POST-TYPE state:`);
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` flushedText: "${state?.flushedText}"`);
|
||||
console.log(` overlayVisible: ${state?.overlayVisible}`);
|
||||
console.log(` displayedText: "${state?.displayedText}"`);
|
||||
console.log(` overlayChildCount: ${state?.overlayChildCount}`);
|
||||
|
||||
// THE CRITICAL ASSERTION: flushed text should still be tracked
|
||||
if (state?.flushedOffset === 11) {
|
||||
pass('Flushed offset preserved after typing space');
|
||||
} else {
|
||||
fail('Flushed offset after space', `expected 11, got ${state?.flushedOffset}`);
|
||||
}
|
||||
|
||||
// The displayed text should include BOTH flushed text AND new space
|
||||
const expectedDisplay = 'hello world ';
|
||||
if (state?.displayedText?.startsWith('hello world')) {
|
||||
pass('Displayed text includes flushed + new text');
|
||||
} else {
|
||||
fail('Displayed text', `expected starts with "hello world", got "${state?.displayedText}"`);
|
||||
}
|
||||
|
||||
if (state?.overlayVisible) {
|
||||
pass('Overlay visible after typing');
|
||||
} else {
|
||||
fail('Overlay visibility', 'overlay is hidden after typing');
|
||||
}
|
||||
|
||||
// ── STEP 6: Type more characters ──
|
||||
console.log('\n--- Step 6: Type "more" after the space ---');
|
||||
await page.keyboard.type('more', { delay: 50 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '06-typed-more-after-space');
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` displayedText: "${state?.displayedText}"`);
|
||||
|
||||
if (state?.pendingText === ' more') {
|
||||
pass('Pending text is " more"');
|
||||
} else {
|
||||
fail('Pending text after more typing', `expected " more", got "${state?.pendingText}"`);
|
||||
}
|
||||
|
||||
const expectedFullDisplay = 'hello world more';
|
||||
if (state?.displayedText?.includes('hello world') && state?.displayedText?.includes('more')) {
|
||||
pass('Full displayed text includes flushed + all new text');
|
||||
} else {
|
||||
fail('Full display', `expected "${expectedFullDisplay}", got "${state?.displayedText}"`);
|
||||
}
|
||||
|
||||
// ── STEP 7: Backspace through new text into flushed text ──
|
||||
console.log('\n--- Step 7: Backspace through new and into flushed text ---');
|
||||
// Delete " more" (5 chars) + 3 from flushed
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '07-after-backspace-into-flushed');
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` flushedText: "${state?.flushedText}"`);
|
||||
console.log(` displayedText: "${state?.displayedText}"`);
|
||||
|
||||
// Should have deleted 5 pending + 3 flushed = flushedOffset 8
|
||||
if (state?.pendingText === '') {
|
||||
pass('Pending text empty after backspace');
|
||||
} else {
|
||||
fail('Pending text after backspace', `expected "", got "${state?.pendingText}"`);
|
||||
}
|
||||
|
||||
if (state?.flushedOffset === 8) {
|
||||
pass('Flushed offset correctly decremented (11 - 3 = 8)');
|
||||
} else {
|
||||
fail('Flushed offset after backspace', `expected 8, got ${state?.flushedOffset}`);
|
||||
}
|
||||
|
||||
// ── STEP 8: Submit with Enter ──
|
||||
console.log('\n--- Step 8: Press Enter to submit ---');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '08-after-enter');
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` overlayVisible: ${state?.overlayVisible}`);
|
||||
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
|
||||
|
||||
if (!state?.overlayVisible && state?.pendingText === '' && state?.flushedOffset === 0) {
|
||||
pass('Overlay fully cleared after Enter');
|
||||
} else {
|
||||
fail('State after Enter', `visible=${state?.overlayVisible}, pending="${state?.pendingText}", flushed=${state?.flushedOffset}`);
|
||||
}
|
||||
|
||||
if (!state?.flushedOffsetsMap?.[sessionA]) {
|
||||
pass('Flushed map cleared for session A after Enter');
|
||||
} else {
|
||||
fail('Flushed map after Enter', `still has A: ${state?.flushedOffsetsMap?.[sessionA]}`);
|
||||
}
|
||||
|
||||
// ── STEP 9: Repeat the cycle — ensure it works multiple times ──
|
||||
console.log('\n--- Step 9: Second cycle — type, switch, switch back, type ---');
|
||||
await page.keyboard.type('second-round', { delay: 40 });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch to B
|
||||
await clickTab(page, sessionB);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Switch back to A
|
||||
await clickTab(page, sessionA);
|
||||
await page.waitForTimeout(3000);
|
||||
await focusTerminal(page);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
console.log(` After 2nd cycle — flushedOffset: ${state?.flushedOffset}, flushedText: "${state?.flushedText}"`);
|
||||
|
||||
// Type space + more text
|
||||
await page.keyboard.type(' again', { delay: 40 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
state = await getOverlayState(page);
|
||||
await screenshot(page, '09-second-cycle');
|
||||
console.log(` pendingText: "${state?.pendingText}"`);
|
||||
console.log(` flushedOffset: ${state?.flushedOffset}`);
|
||||
console.log(` displayedText: "${state?.displayedText}"`);
|
||||
|
||||
if (state?.displayedText?.includes('second-round') && state?.displayedText?.includes('again')) {
|
||||
pass('Second cycle: flushed + new text displayed correctly');
|
||||
} else {
|
||||
fail('Second cycle display', `expected "second-round again", got "${state?.displayedText}"`);
|
||||
}
|
||||
|
||||
// ── SUMMARY ──
|
||||
console.log('\n=== SUMMARY ===');
|
||||
const passes = results.filter(r => r.ok).length;
|
||||
const fails = results.filter(r => !r.ok).length;
|
||||
console.log(` ${passes} passed, ${fails} failed`);
|
||||
console.log(` Screenshots: ${SCREENSHOT_DIR}/`);
|
||||
|
||||
if (fails > 0) {
|
||||
console.log('\n FAILURES:');
|
||||
for (const r of results.filter(r => !r.ok)) {
|
||||
console.log(` ✗ ${r.name}: ${r.detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (jsErrors.length > 0) {
|
||||
console.log(`\n JS errors (${jsErrors.length}):`);
|
||||
for (const e of jsErrors.slice(0, 10)) console.log(` ${e}`);
|
||||
}
|
||||
|
||||
console.log(`\n Exit code: ${fails > 0 ? 1 : 0}`);
|
||||
process.exitCode = fails > 0 ? 1 : 0;
|
||||
|
||||
} 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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
await page.locator('.session-tab').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Check ALL background colors in the xterm rendering stack
|
||||
const bgInfo = await page.evaluate(() => {
|
||||
const term = window.app?.terminal;
|
||||
if (!term) return { error: 'no terminal' };
|
||||
|
||||
const el = term.element;
|
||||
if (!el) return { error: 'no element' };
|
||||
|
||||
const results = {};
|
||||
|
||||
// Walk up from xterm-screen to find who provides the background
|
||||
const screen = el.querySelector('.xterm-screen');
|
||||
const viewport = el.querySelector('.xterm-viewport');
|
||||
const rows = el.querySelector('.xterm-rows');
|
||||
|
||||
const elements = {
|
||||
'.xterm (term.element)': el,
|
||||
'.xterm-viewport': viewport,
|
||||
'.xterm-screen': screen,
|
||||
'.xterm-rows': rows,
|
||||
};
|
||||
|
||||
// Also check parent elements
|
||||
let parent = el.parentElement;
|
||||
let depth = 0;
|
||||
while (parent && depth < 5) {
|
||||
elements[`parent-${depth} (${parent.tagName}.${parent.className?.split(' ')[0] || ''})`] = parent;
|
||||
parent = parent.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
for (const [name, elem] of Object.entries(elements)) {
|
||||
if (!elem) { results[name] = 'not found'; continue; }
|
||||
const cs = getComputedStyle(elem);
|
||||
results[name] = {
|
||||
background: cs.background?.slice(0, 80),
|
||||
backgroundColor: cs.backgroundColor,
|
||||
inlineStyle: elem.style.backgroundColor || elem.style.background || '(none)',
|
||||
};
|
||||
}
|
||||
|
||||
// Also check if xterm-viewport has inline style set by xterm.js
|
||||
if (viewport) {
|
||||
results['viewport-inline-full'] = viewport.style.cssText?.slice(0, 200);
|
||||
}
|
||||
|
||||
// Theme config
|
||||
results.theme = term.options?.theme;
|
||||
|
||||
// Overlay bg
|
||||
results.overlayBg = window.app?._localEchoOverlay?._font?.backgroundColor;
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(bgInfo, null, 2));
|
||||
await browser.close();
|
||||
@@ -1,85 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
await page.locator('.session-tab').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Sample the ACTUAL canvas pixel color at an empty area
|
||||
const colorInfo = await page.evaluate(() => {
|
||||
const term = window.app?.terminal;
|
||||
if (!term) return { error: 'no terminal' };
|
||||
|
||||
// Find the WebGL canvas
|
||||
const screen = term.element?.querySelector('.xterm-screen');
|
||||
const canvases = screen?.querySelectorAll('canvas');
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const canvas of canvases || []) {
|
||||
const ctx = canvas.getContext('2d') || canvas.getContext('webgl') || canvas.getContext('webgl2');
|
||||
const ctxType = ctx?.constructor?.name;
|
||||
|
||||
if (ctx && (ctxType === 'CanvasRenderingContext2D')) {
|
||||
// 2D context — can read pixels directly
|
||||
try {
|
||||
const pixel = ctx.getImageData(10, 10, 1, 1).data;
|
||||
results['2d'] = {
|
||||
r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3],
|
||||
hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
} catch (e) {
|
||||
results['2d'] = { error: e.message };
|
||||
}
|
||||
} else if (ctx) {
|
||||
// WebGL context — use readPixels
|
||||
try {
|
||||
const gl = ctx;
|
||||
const pixel = new Uint8Array(4);
|
||||
// Read from an empty area (bottom-left corner, row 0 col 0)
|
||||
gl.readPixels(10, canvas.height - 10, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
|
||||
results['webgl'] = {
|
||||
r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3],
|
||||
hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''),
|
||||
};
|
||||
} catch (e) {
|
||||
results['webgl'] = { error: e.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check what the terminal theme says
|
||||
results.theme = {
|
||||
background: term.options?.theme?.background,
|
||||
foreground: term.options?.theme?.foreground,
|
||||
};
|
||||
|
||||
// Check the overlay's configured background
|
||||
const overlay = window.app?._localEchoOverlay;
|
||||
results.overlayBg = overlay?._font?.backgroundColor;
|
||||
|
||||
// Check xterm-rows computed style
|
||||
const rows = term.element?.querySelector('.xterm-rows');
|
||||
if (rows) {
|
||||
results.rowsBg = getComputedStyle(rows).backgroundColor;
|
||||
}
|
||||
|
||||
// Check .xterm element bg
|
||||
const xtermEl = term.element;
|
||||
if (xtermEl) {
|
||||
results.xtermBg = getComputedStyle(xtermEl).backgroundColor;
|
||||
}
|
||||
|
||||
// Canvas count and types
|
||||
results.canvasCount = canvases?.length;
|
||||
results.canvasClasses = Array.from(canvases || []).map(c => c.className);
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(colorInfo, null, 2));
|
||||
await browser.close();
|
||||
console.log('Done');
|
||||
@@ -1,48 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
const firstTab = page.locator('.session-tab').first();
|
||||
await firstTab.click();
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const term = window.app?.terminal;
|
||||
if (!term) return { error: 'no app.terminal' };
|
||||
|
||||
const core = term._core;
|
||||
const renderService = core?._renderService;
|
||||
const dims = renderService?.dimensions;
|
||||
|
||||
if (!dims) return { error: 'no dimensions', hasCore: !!core, hasRenderService: !!renderService };
|
||||
|
||||
return {
|
||||
css: {
|
||||
cell: dims.css?.cell,
|
||||
char: dims.css?.char,
|
||||
canvas: dims.css?.canvas,
|
||||
},
|
||||
device: {
|
||||
cell: dims.device?.cell,
|
||||
char: dims.device?.char,
|
||||
canvas: dims.device?.canvas,
|
||||
},
|
||||
topLevelKeys: Object.keys(dims),
|
||||
cssKeys: dims.css ? Object.keys(dims.css) : [],
|
||||
deviceKeys: dims.device ? Object.keys(dims.device) : [],
|
||||
cssAllDetail: dims.css ? Object.fromEntries(
|
||||
Object.entries(dims.css).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v])
|
||||
) : null,
|
||||
deviceAllDetail: dims.device ? Object.fromEntries(
|
||||
Object.entries(dims.device).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v])
|
||||
) : null,
|
||||
dpr: window.devicePixelRatio,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
await browser.close();
|
||||
@@ -1,87 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true, deviceScaleFactor: 2 });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
await page.locator('.session-tab').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
|
||||
settings.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-settings', JSON.stringify(settings));
|
||||
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Force render and capture zoomed area of just the overlay
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = window.app?._localEchoOverlay;
|
||||
if (!overlay || !overlay._overlay) return { error: 'no overlay' };
|
||||
|
||||
overlay._lastPromptPos = { row: 5, col: 0 };
|
||||
overlay._pendingText = 'h';
|
||||
overlay._lastRenderKey = '';
|
||||
overlay._render();
|
||||
|
||||
const container = overlay._overlay;
|
||||
const lineDiv = container.querySelector('div');
|
||||
const charSpan = container.querySelector('div > span');
|
||||
|
||||
// Get precise rects
|
||||
const lineDivRect = lineDiv?.getBoundingClientRect();
|
||||
const charSpanRect = charSpan?.getBoundingClientRect();
|
||||
const screenRect = document.querySelector('.xterm-screen')?.getBoundingClientRect();
|
||||
|
||||
// Check what's right below the line div
|
||||
const belowY = (lineDivRect?.bottom || 0) + 1;
|
||||
const belowX = lineDivRect?.left || 0;
|
||||
const elBelow = document.elementFromPoint(belowX, belowY);
|
||||
|
||||
return {
|
||||
dpr: window.devicePixelRatio,
|
||||
lineDiv: {
|
||||
rect: lineDivRect,
|
||||
bg: lineDiv?.style.backgroundColor,
|
||||
height: lineDiv?.style.height,
|
||||
},
|
||||
charSpan: {
|
||||
rect: charSpanRect,
|
||||
transform: charSpan?.style.transform,
|
||||
// Check if span bottom exceeds lineDiv bottom
|
||||
extendsBelow: charSpanRect && lineDivRect ?
|
||||
(charSpanRect.bottom - lineDivRect.bottom).toFixed(2) : 'N/A',
|
||||
},
|
||||
elBelow: elBelow ? {
|
||||
tag: elBelow.tagName,
|
||||
class: elBelow.className,
|
||||
} : null,
|
||||
// Check the exact background color of the canvas
|
||||
canvasBg: document.querySelector('.xterm-screen')?.style.backgroundColor,
|
||||
themeBg: window.app?.terminal?.options?.theme?.background,
|
||||
overlayBg: lineDiv?.style.backgroundColor,
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
// Take tight screenshot around the first char area
|
||||
const screenEl = page.locator('.xterm-screen').first();
|
||||
const screenBox = await screenEl.boundingBox();
|
||||
if (screenBox) {
|
||||
// Crop to just the overlay area (row 5, first few columns)
|
||||
const cellH = 19;
|
||||
const promptRow = 5;
|
||||
const cropY = screenBox.y + (promptRow - 1) * cellH;
|
||||
const cropH = cellH * 3;
|
||||
await page.screenshot({
|
||||
path: '/tmp/first-char-zoomed.png',
|
||||
clip: { x: screenBox.x, y: cropY, width: 200, height: cropH },
|
||||
});
|
||||
console.log('Zoomed screenshot saved');
|
||||
}
|
||||
|
||||
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
|
||||
await browser.close();
|
||||
console.log('Done');
|
||||
@@ -1,116 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const SESSION_ID = '9270f4e4-84f7-414c-b901-837f12c30e59';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') console.log('CONSOLE ERROR:', msg.text());
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Click on test session tab
|
||||
const tab = page.locator(`.session-tab[data-id="${SESSION_ID}"]`);
|
||||
if (await tab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await tab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
} else {
|
||||
console.log('Tab not found, using whatever is active');
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Ensure local echo is OFF
|
||||
const localEchoToggle = page.locator('#localEchoToggle');
|
||||
const isChecked = await localEchoToggle.isChecked().catch(() => false);
|
||||
console.log('Local echo initially:', isChecked);
|
||||
|
||||
if (isChecked) {
|
||||
await localEchoToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Type with echo OFF
|
||||
await page.locator('.xterm-helper-textarea').first().focus();
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.type('hello test', { delay: 80 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const termScreen = page.locator('.xterm-screen').first();
|
||||
await termScreen.screenshot({ path: '/tmp/echo-off-term.png' });
|
||||
console.log('Saved /tmp/echo-off-term.png');
|
||||
|
||||
// Clear
|
||||
for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Enable local echo
|
||||
await localEchoToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
console.log('Local echo now:', await localEchoToggle.isChecked().catch(() => false));
|
||||
|
||||
// Type with echo ON
|
||||
await page.locator('.xterm-helper-textarea').first().focus();
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.type('hello test', { delay: 80 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await termScreen.screenshot({ path: '/tmp/echo-on-term.png' });
|
||||
console.log('Saved /tmp/echo-on-term.png');
|
||||
await page.screenshot({ path: '/tmp/echo-on-full.png' });
|
||||
|
||||
// Inspect overlay DOM
|
||||
const overlayInfo = await page.evaluate(() => {
|
||||
const overlay = document.querySelector('.zl-overlay');
|
||||
if (!overlay) return { found: false };
|
||||
|
||||
const firstDiv = overlay.querySelector('div');
|
||||
const spans = overlay.querySelectorAll('span');
|
||||
const firstCharSpan = Array.from(spans).find(s => s.textContent && s.textContent.trim());
|
||||
|
||||
return {
|
||||
found: true,
|
||||
display: overlay.style.display,
|
||||
top: overlay.style.top,
|
||||
containerOverflow: overlay.style.overflow,
|
||||
containerHeight: overlay.style.height,
|
||||
firstDiv: firstDiv ? {
|
||||
height: firstDiv.style.height,
|
||||
top: firstDiv.style.top,
|
||||
overflow: firstDiv.style.overflow,
|
||||
backgroundColor: firstDiv.style.backgroundColor,
|
||||
} : null,
|
||||
firstCharSpan: firstCharSpan ? {
|
||||
text: firstCharSpan.textContent,
|
||||
transform: firstCharSpan.style.transform,
|
||||
top: firstCharSpan.style.top,
|
||||
height: firstCharSpan.style.height,
|
||||
lineHeight: firstCharSpan.style.lineHeight,
|
||||
width: firstCharSpan.style.width,
|
||||
} : null,
|
||||
spanCount: spans.length,
|
||||
};
|
||||
});
|
||||
console.log('\nOverlay info:', JSON.stringify(overlayInfo, null, 2));
|
||||
|
||||
// Cell dimensions
|
||||
const cellInfo = await page.evaluate(() => {
|
||||
if (window.app?.sessions) {
|
||||
for (const [, s] of Object.entries(window.app.sessions)) {
|
||||
const dims = s.terminal?._core?._renderService?.dimensions;
|
||||
if (dims) return { css: dims.css?.cell, device_char: dims.device?.char };
|
||||
}
|
||||
}
|
||||
return { error: 'not found' };
|
||||
});
|
||||
console.log('Cell dims:', JSON.stringify(cellInfo, null, 2));
|
||||
|
||||
// Cleanup
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch('/api/sessions/' + id, { method: 'DELETE' });
|
||||
}, SESSION_ID);
|
||||
|
||||
await browser.close();
|
||||
console.log('\nDone');
|
||||
@@ -1,114 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
page.on('console', msg => console.log('CONSOLE:', msg.type(), msg.text()));
|
||||
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Create a fresh session
|
||||
const resp = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'ov-test', mode: 'claude' })
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
const sid = resp.session?.id;
|
||||
console.log('Session:', sid?.slice(0, 8));
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Click on it
|
||||
await page.locator(`.session-tab[data-id="${sid}"]`).click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check for the local echo toggle
|
||||
const toggle = page.locator('#localEchoToggle');
|
||||
const toggleVisible = await toggle.isVisible().catch(() => false);
|
||||
console.log('Toggle visible:', toggleVisible);
|
||||
|
||||
if (!toggleVisible) {
|
||||
// Maybe need to open settings or it's somewhere else
|
||||
console.log('Looking for local echo toggle...');
|
||||
const all = await page.locator('input[type="checkbox"]').all();
|
||||
for (const cb of all) {
|
||||
const id = await cb.getAttribute('id');
|
||||
const label = await cb.evaluate(el => el.parentElement?.textContent?.trim().slice(0, 30));
|
||||
console.log(' checkbox:', id, label);
|
||||
}
|
||||
}
|
||||
|
||||
// Get cell dimensions first
|
||||
const cellInfo = await page.evaluate(() => {
|
||||
if (window.app?.sessions) {
|
||||
for (const [id, s] of Object.entries(window.app.sessions)) {
|
||||
const dims = s.terminal?._core?._renderService?.dimensions;
|
||||
if (dims) return {
|
||||
id: id.slice(0, 8),
|
||||
cssCell: dims.css?.cell,
|
||||
deviceChar: dims.device?.char,
|
||||
actualFull: dims,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { error: 'no terminal' };
|
||||
});
|
||||
console.log('Cell info:', JSON.stringify(cellInfo, null, 2));
|
||||
|
||||
// Focus terminal and type with echo OFF
|
||||
const textarea = page.locator('.xterm-helper-textarea').first();
|
||||
await textarea.focus();
|
||||
await page.waitForTimeout(500);
|
||||
await page.keyboard.type('hello test', { delay: 80 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.screenshot({ path: '/tmp/echo-off-full.png' });
|
||||
console.log('Saved echo-off-full.png');
|
||||
|
||||
// Clear
|
||||
for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Enable local echo
|
||||
if (toggleVisible) {
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
console.log('Toggled local echo ON');
|
||||
}
|
||||
|
||||
// Type again
|
||||
await textarea.focus();
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.type('hello test', { delay: 80 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.screenshot({ path: '/tmp/echo-on-full.png' });
|
||||
console.log('Saved echo-on-full.png');
|
||||
|
||||
// Get overlay info
|
||||
const oi = await page.evaluate(() => {
|
||||
const o = document.querySelector('.zl-overlay');
|
||||
if (!o) return { found: false, html: 'none' };
|
||||
return {
|
||||
found: true,
|
||||
display: o.style.display,
|
||||
top: o.style.top,
|
||||
childCount: o.children.length,
|
||||
innerHTML: o.innerHTML.slice(0, 500),
|
||||
};
|
||||
});
|
||||
console.log('Overlay:', JSON.stringify(oi, null, 2));
|
||||
|
||||
// Cleanup
|
||||
if (sid) {
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch('/api/sessions/' + id, { method: 'DELETE' });
|
||||
}, sid);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('Done');
|
||||
@@ -1,109 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
// Click first tab
|
||||
await page.locator('.session-tab').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Get terminal and enable local echo via JS
|
||||
const result = await page.evaluate(() => {
|
||||
const term = window.app.terminal;
|
||||
if (!term) return { error: 'no terminal' };
|
||||
|
||||
const dims = term._core._renderService.dimensions;
|
||||
|
||||
// Toggle local echo on via settings
|
||||
const checkbox = document.getElementById('appSettingsLocalEcho');
|
||||
|
||||
return {
|
||||
cellW: dims.css.cell.width,
|
||||
cellH: dims.css.cell.height,
|
||||
charTop: dims.device?.char?.top ?? 0,
|
||||
charHeight: dims.device?.char?.height ?? 0,
|
||||
dpr: window.devicePixelRatio,
|
||||
fontSize: term.options.fontSize,
|
||||
fontFamily: term.options.fontFamily,
|
||||
localEchoCheckbox: !!checkbox,
|
||||
localEchoChecked: checkbox?.checked,
|
||||
};
|
||||
});
|
||||
console.log('Terminal info:', JSON.stringify(result, null, 2));
|
||||
|
||||
// Enable local echo via the app settings checkbox
|
||||
await page.evaluate(() => {
|
||||
const cb = document.getElementById('appSettingsLocalEcho');
|
||||
if (cb && !cb.checked) {
|
||||
cb.click();
|
||||
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Focus terminal and type
|
||||
await page.locator('.xterm-helper-textarea').first().focus();
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.type('hello', { delay: 100 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Take screenshots and analyze pixel positions
|
||||
const pixelInfo = await page.evaluate(() => {
|
||||
const overlay = document.querySelector('.zl-overlay');
|
||||
if (!overlay) return { error: 'no overlay', overlayDisplay: overlay?.style?.display };
|
||||
|
||||
const spans = overlay.querySelectorAll('span');
|
||||
const firstCharSpan = Array.from(spans).find(s => s.textContent === 'h');
|
||||
|
||||
if (!firstCharSpan) {
|
||||
return { error: 'no h span', spanCount: spans.length, innerHTML: overlay.innerHTML.slice(0, 200) };
|
||||
}
|
||||
|
||||
const spanRect = firstCharSpan.getBoundingClientRect();
|
||||
const containerRect = overlay.getBoundingClientRect();
|
||||
const lineDiv = firstCharSpan.parentElement;
|
||||
const lineDivRect = lineDiv.getBoundingClientRect();
|
||||
|
||||
// Get the canvas position for comparison
|
||||
const canvasEl = document.querySelector('.xterm-screen canvas');
|
||||
const canvasRect = canvasEl?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
span: {
|
||||
top: spanRect.top,
|
||||
bottom: spanRect.bottom,
|
||||
height: spanRect.height,
|
||||
relativeTop: spanRect.top - lineDivRect.top,
|
||||
},
|
||||
lineDiv: {
|
||||
top: lineDivRect.top,
|
||||
bottom: lineDivRect.bottom,
|
||||
height: lineDivRect.height,
|
||||
},
|
||||
container: {
|
||||
top: containerRect.top,
|
||||
height: containerRect.height,
|
||||
},
|
||||
canvas: canvasRect ? {
|
||||
top: canvasRect.top,
|
||||
} : null,
|
||||
spanStyle: {
|
||||
transform: firstCharSpan.style.transform,
|
||||
top: firstCharSpan.style.top,
|
||||
height: firstCharSpan.style.height,
|
||||
lineHeight: firstCharSpan.style.lineHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
console.log('Pixel info:', JSON.stringify(pixelInfo, null, 2));
|
||||
|
||||
// Screenshot the terminal area
|
||||
const termScreen = page.locator('.xterm-screen').first();
|
||||
await termScreen.screenshot({ path: '/tmp/overlay-test-zoomed.png' });
|
||||
console.log('Screenshot saved to /tmp/overlay-test-zoomed.png');
|
||||
|
||||
await browser.close();
|
||||
console.log('Done');
|
||||
@@ -1,69 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
await page.locator('.session-tab').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Enable local echo
|
||||
await page.evaluate(() => {
|
||||
const s = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
|
||||
s.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-settings', JSON.stringify(s));
|
||||
window.app?.updateLocalEchoState?.();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Force render overlay with single char at a realistic prompt position
|
||||
await page.evaluate(() => {
|
||||
const ov = window.app?._localEchoOverlay;
|
||||
if (!ov) return;
|
||||
// Find actual bottom row for a realistic position
|
||||
const buf = ov._terminal?.buffer?.active;
|
||||
const row = buf ? Math.max(0, buf.baseY + ov._terminal.rows - 2) - buf.viewportY : 5;
|
||||
ov._lastPromptPos = { row, col: 0 };
|
||||
ov._pendingText = 'hello test';
|
||||
ov._lastRenderKey = '';
|
||||
ov._render();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Take full-page and zoomed screenshots
|
||||
await page.screenshot({ path: '/tmp/sc-full.png' });
|
||||
|
||||
// Zoom into overlay area — find where the overlay actually is
|
||||
const overlayRect = await page.evaluate(() => {
|
||||
const ov = window.app?._localEchoOverlay?._overlay;
|
||||
if (!ov) return null;
|
||||
const r = ov.getBoundingClientRect();
|
||||
const lineDiv = ov.querySelector('div');
|
||||
const lr = lineDiv?.getBoundingClientRect();
|
||||
return {
|
||||
container: { x: r.x, y: r.y, w: r.width, h: r.height },
|
||||
lineDiv: lr ? { x: lr.x, y: lr.y, w: lr.width, h: lr.height } : null,
|
||||
};
|
||||
});
|
||||
console.log('Overlay rect:', JSON.stringify(overlayRect));
|
||||
|
||||
if (overlayRect?.lineDiv) {
|
||||
const ld = overlayRect.lineDiv;
|
||||
// Capture area around the overlay: 1 row above, the overlay, 1 row below
|
||||
const margin = 20; // ~1 cell height
|
||||
await page.screenshot({
|
||||
path: '/tmp/sc-overlay-zoomed.png',
|
||||
clip: {
|
||||
x: Math.max(0, ld.x - 5),
|
||||
y: Math.max(0, ld.y - margin),
|
||||
width: Math.min(400, ld.w),
|
||||
height: ld.h + margin * 2 + 5,
|
||||
},
|
||||
});
|
||||
console.log('Zoomed screenshot saved');
|
||||
}
|
||||
|
||||
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
|
||||
await browser.close();
|
||||
console.log('Done');
|
||||
@@ -1,69 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
await page.locator('.session-tab').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Enable local echo and update state
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
|
||||
settings.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-settings', JSON.stringify(settings));
|
||||
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Force render with fake prompt position and inspect
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = window.app?._localEchoOverlay;
|
||||
if (!overlay || !overlay._overlay) return { error: 'no overlay' };
|
||||
|
||||
const term = overlay._terminal;
|
||||
const dims = term._core._renderService.dimensions;
|
||||
const cellH = dims.css.cell.height;
|
||||
const charTop = dims.device?.char?.top ?? 0;
|
||||
const charHeight = dims.device?.char?.height ?? cellH;
|
||||
|
||||
// Calculate the correction
|
||||
const cssCenter = (cellH - charHeight) / 2;
|
||||
const correction = charTop - cssCenter;
|
||||
|
||||
// Force render
|
||||
overlay._lastPromptPos = { row: 5, col: 0 };
|
||||
overlay._pendingText = 'hello test';
|
||||
overlay._lastRenderKey = '';
|
||||
overlay._render();
|
||||
|
||||
const spans = overlay._overlay.querySelectorAll('span');
|
||||
const firstChar = Array.from(spans).find(s => s.textContent === 'h');
|
||||
|
||||
return {
|
||||
cellH, charTop, charHeight,
|
||||
cssCenter,
|
||||
correction: correction.toFixed(2),
|
||||
firstChar: firstChar ? {
|
||||
top: firstChar.style.top,
|
||||
height: firstChar.style.height,
|
||||
lineHeight: firstChar.style.lineHeight,
|
||||
transform: firstChar.style.transform,
|
||||
} : null,
|
||||
};
|
||||
});
|
||||
console.log('Fix result:', JSON.stringify(result, null, 2));
|
||||
|
||||
// Take screenshot
|
||||
await page.locator('.xterm-screen').first().screenshot({ path: '/tmp/fix-verify-term.png' });
|
||||
console.log('Screenshot saved');
|
||||
|
||||
// Clean up
|
||||
await page.evaluate(() => {
|
||||
window.app?._localEchoOverlay?.clear();
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log('Done');
|
||||
@@ -1,61 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(6000);
|
||||
await page.locator('.session-tab').first().click();
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
|
||||
settings.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-settings', JSON.stringify(settings));
|
||||
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
|
||||
});
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = window.app?._localEchoOverlay;
|
||||
if (!overlay) return { error: 'no overlay' };
|
||||
|
||||
overlay._lastPromptPos = { row: 5, col: 0 };
|
||||
overlay._pendingText = 'hello test';
|
||||
overlay._lastRenderKey = '';
|
||||
overlay._render();
|
||||
|
||||
const container = overlay._overlay;
|
||||
const lineDiv = container.querySelector('div');
|
||||
const charSpan = container.querySelector('div > span');
|
||||
|
||||
const lineDivRect = lineDiv?.getBoundingClientRect();
|
||||
const charSpanRect = charSpan?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
lineDiv: { bottom: lineDivRect?.bottom, height: lineDivRect?.height },
|
||||
charSpan: {
|
||||
bottom: charSpanRect?.bottom,
|
||||
height: charSpanRect?.height,
|
||||
transform: charSpan?.style.transform,
|
||||
top: charSpan?.style.top,
|
||||
lineHeight: charSpan?.style.lineHeight,
|
||||
},
|
||||
extendsBelow: charSpanRect && lineDivRect ?
|
||||
(charSpanRect.bottom - lineDivRect.bottom).toFixed(4) : 'N/A',
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
// Screenshot
|
||||
const screenEl = page.locator('.xterm-screen').first();
|
||||
const screenBox = await screenEl.boundingBox();
|
||||
if (screenBox) {
|
||||
await page.screenshot({
|
||||
path: '/tmp/no-overhang-zoomed.png',
|
||||
clip: { x: screenBox.x, y: screenBox.y + 4 * 19, width: 200, height: 57 },
|
||||
});
|
||||
}
|
||||
|
||||
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
|
||||
await browser.close();
|
||||
console.log('Done');
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* Shared MockSession for tests that need terminal simulation.
|
||||
*
|
||||
* Copied from test/respawn-test-utils.ts (the canonical, most complete version).
|
||||
* Used by respawn, route, and subagent tests.
|
||||
*/
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* Reusable async test helpers.
|
||||
* Extracted from respawn-test-utils.ts.
|
||||
*/
|
||||
|
||||
/** Wait for an EventEmitter to emit a specific event, with timeout */
|
||||
export function waitForEvent(
|
||||
emitter: { once: (event: string, listener: (...args: unknown[]) => void) => void },
|
||||
event: string,
|
||||
timeoutMs = 5000,
|
||||
timeoutMs = 5000
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`Timed out waiting for event "${event}" after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
timeoutMs
|
||||
);
|
||||
emitter.once(event, (...args: unknown[]) => {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -1,727 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Test Utilities for RespawnController Tests
|
||||
*
|
||||
* Provides respawn-specific mocks, helpers, and utilities for testing the
|
||||
* RespawnController and its related AI checker components.
|
||||
*
|
||||
* General-purpose mocks (MockSession, terminalOutputs, etc.) have been moved
|
||||
* to test/mocks/ — re-exported here for backward compatibility.
|
||||
*
|
||||
* @module test/respawn-test-utils
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { vi } from 'vitest';
|
||||
import type { Session } from '../src/session.js';
|
||||
import type { RespawnConfig, RespawnState } from '../src/respawn-controller.js';
|
||||
import type {
|
||||
AiCheckResult,
|
||||
AiCheckState,
|
||||
AiCheckStatus,
|
||||
AiCheckVerdict,
|
||||
AiIdleCheckConfig,
|
||||
} from '../src/ai-idle-checker.js';
|
||||
import type {
|
||||
AiPlanCheckResult,
|
||||
AiPlanCheckState,
|
||||
AiPlanCheckStatus,
|
||||
AiPlanCheckVerdict,
|
||||
AiPlanCheckConfig,
|
||||
} from '../src/ai-plan-checker.js';
|
||||
|
||||
// Re-export shared mocks for backward compatibility
|
||||
export { MockSession, createMockSession, terminalOutputs } from './mocks/mock-session.js';
|
||||
export { waitForEvent, createDeferred } from './mocks/test-helpers.js';
|
||||
|
||||
// ========== Time Manipulation Utilities ==========
|
||||
|
||||
/**
|
||||
* Time controller for testing timeout-based behavior.
|
||||
* Uses vitest's fake timers for deterministic time control.
|
||||
*/
|
||||
export interface TimeController {
|
||||
/** Advance time by the specified milliseconds */
|
||||
advanceBy(ms: number): Promise<void>;
|
||||
/** Advance to the next timer */
|
||||
advanceToNextTimer(): Promise<void>;
|
||||
/** Run all pending timers */
|
||||
runAllTimers(): Promise<void>;
|
||||
/** Get the current fake time */
|
||||
now(): number;
|
||||
/** Reset to real timers */
|
||||
useRealTimers(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a time controller for testing timeout-based behavior.
|
||||
* Call this in beforeEach and cleanup with controller.useRealTimers() in afterEach.
|
||||
*/
|
||||
export function createTimeController(): TimeController {
|
||||
vi.useFakeTimers();
|
||||
|
||||
return {
|
||||
async advanceBy(ms: number): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
},
|
||||
async advanceToNextTimer(): Promise<void> {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
},
|
||||
async runAllTimers(): Promise<void> {
|
||||
await vi.runAllTimersAsync();
|
||||
},
|
||||
now(): number {
|
||||
return Date.now();
|
||||
},
|
||||
useRealTimers(): void {
|
||||
vi.useRealTimers();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========== MockAiIdleChecker ==========
|
||||
|
||||
/**
|
||||
* Configuration for MockAiIdleChecker behavior.
|
||||
*/
|
||||
export interface MockAiIdleCheckerOptions {
|
||||
/** Default verdict to return */
|
||||
defaultVerdict?: AiCheckVerdict;
|
||||
/** Default reasoning to include */
|
||||
defaultReasoning?: string;
|
||||
/** Simulated check duration in ms */
|
||||
checkDurationMs?: number;
|
||||
/** Whether to start in disabled state */
|
||||
startDisabled?: boolean;
|
||||
/** Reason if starting disabled */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock AI idle checker for testing without spawning real Claude CLI.
|
||||
* Allows configuring verdicts and simulating various states.
|
||||
*/
|
||||
export class MockAiIdleChecker extends EventEmitter {
|
||||
private config: AiIdleCheckConfig;
|
||||
private _status: AiCheckStatus = 'ready';
|
||||
private _lastVerdict: AiCheckVerdict | null = null;
|
||||
private _lastReasoning: string | null = null;
|
||||
private _lastCheckDurationMs: number | null = null;
|
||||
private _cooldownEndsAt: number | null = null;
|
||||
private _consecutiveErrors: number = 0;
|
||||
private _totalChecks: number = 0;
|
||||
private _disabledReason: string | null = null;
|
||||
|
||||
// Queued results for sequential calls
|
||||
private queuedResults: AiCheckResult[] = [];
|
||||
private defaultOptions: MockAiIdleCheckerOptions;
|
||||
private cooldownTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
public sessionId: string,
|
||||
config: Partial<AiIdleCheckConfig> = {},
|
||||
options: MockAiIdleCheckerOptions = {},
|
||||
) {
|
||||
super();
|
||||
this.config = {
|
||||
enabled: true,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
maxContextChars: 16000,
|
||||
checkTimeoutMs: 90000,
|
||||
cooldownMs: 180000,
|
||||
errorCooldownMs: 60000,
|
||||
maxConsecutiveErrors: 3,
|
||||
...config,
|
||||
};
|
||||
this.defaultOptions = {
|
||||
defaultVerdict: 'IDLE',
|
||||
defaultReasoning: 'Mock verdict',
|
||||
checkDurationMs: 100,
|
||||
startDisabled: false,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (this.defaultOptions.startDisabled) {
|
||||
this._status = 'disabled';
|
||||
this._disabledReason = this.defaultOptions.disabledReason || 'Disabled for testing';
|
||||
}
|
||||
}
|
||||
|
||||
get status(): AiCheckStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
getState(): AiCheckState {
|
||||
return {
|
||||
status: this._status,
|
||||
lastVerdict: this._lastVerdict,
|
||||
lastReasoning: this._lastReasoning,
|
||||
lastCheckDurationMs: this._lastCheckDurationMs,
|
||||
cooldownEndsAt: this._cooldownEndsAt,
|
||||
consecutiveErrors: this._consecutiveErrors,
|
||||
totalChecks: this._totalChecks,
|
||||
disabledReason: this._disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
isOnCooldown(): boolean {
|
||||
return this._cooldownEndsAt !== null && Date.now() < this._cooldownEndsAt;
|
||||
}
|
||||
|
||||
getCooldownRemainingMs(): number {
|
||||
if (this._cooldownEndsAt === null) return 0;
|
||||
return Math.max(0, this._cooldownEndsAt - Date.now());
|
||||
}
|
||||
|
||||
queueResult(result: AiCheckResult): void {
|
||||
this.queuedResults.push(result);
|
||||
}
|
||||
|
||||
queueResults(...results: AiCheckResult[]): void {
|
||||
this.queuedResults.push(...results);
|
||||
}
|
||||
|
||||
setNextIdle(reasoning: string = 'Mock: IDLE'): void {
|
||||
this.queueResult({
|
||||
verdict: 'IDLE',
|
||||
reasoning,
|
||||
durationMs: this.defaultOptions.checkDurationMs || 100,
|
||||
});
|
||||
}
|
||||
|
||||
setNextWorking(reasoning: string = 'Mock: WORKING'): void {
|
||||
this.queueResult({
|
||||
verdict: 'WORKING',
|
||||
reasoning,
|
||||
durationMs: this.defaultOptions.checkDurationMs || 100,
|
||||
});
|
||||
}
|
||||
|
||||
setNextError(reasoning: string = 'Mock: ERROR'): void {
|
||||
this.queueResult({
|
||||
verdict: 'ERROR',
|
||||
reasoning,
|
||||
durationMs: this.defaultOptions.checkDurationMs || 100,
|
||||
});
|
||||
}
|
||||
|
||||
async check(_terminalBuffer: string): Promise<AiCheckResult> {
|
||||
if (this._status === 'disabled') {
|
||||
return { verdict: 'ERROR', reasoning: `Disabled: ${this._disabledReason}`, durationMs: 0 };
|
||||
}
|
||||
|
||||
if (this.isOnCooldown()) {
|
||||
return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 };
|
||||
}
|
||||
|
||||
if (this._status === 'checking') {
|
||||
return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 };
|
||||
}
|
||||
|
||||
this._status = 'checking';
|
||||
this._totalChecks++;
|
||||
this.emit('checkStarted');
|
||||
|
||||
// Simulate async check
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Get result from queue or use default
|
||||
const result = this.queuedResults.shift() || {
|
||||
verdict: this.defaultOptions.defaultVerdict!,
|
||||
reasoning: this.defaultOptions.defaultReasoning!,
|
||||
durationMs: this.defaultOptions.checkDurationMs!,
|
||||
};
|
||||
|
||||
this._lastVerdict = result.verdict;
|
||||
this._lastReasoning = result.reasoning;
|
||||
this._lastCheckDurationMs = result.durationMs;
|
||||
|
||||
if (result.verdict === 'IDLE') {
|
||||
this._consecutiveErrors = 0;
|
||||
this._status = 'ready';
|
||||
} else if (result.verdict === 'WORKING') {
|
||||
this._consecutiveErrors = 0;
|
||||
this.startCooldown(this.config.cooldownMs);
|
||||
} else {
|
||||
this._consecutiveErrors++;
|
||||
if (this._consecutiveErrors >= this.config.maxConsecutiveErrors) {
|
||||
this._status = 'disabled';
|
||||
this._disabledReason = `${this.config.maxConsecutiveErrors} consecutive errors`;
|
||||
this.emit('disabled', this._disabledReason);
|
||||
} else {
|
||||
this.startCooldown(this.config.errorCooldownMs);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('checkCompleted', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this._status === 'checking') {
|
||||
this._status = 'ready';
|
||||
this.emit('log', '[MockAiIdleChecker] Check cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cancel();
|
||||
this.clearCooldown();
|
||||
this._lastVerdict = null;
|
||||
this._lastReasoning = null;
|
||||
this._lastCheckDurationMs = null;
|
||||
this._consecutiveErrors = 0;
|
||||
this.queuedResults = [];
|
||||
this._status = this._disabledReason ? 'disabled' : 'ready';
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<AiIdleCheckConfig>): void {
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined),
|
||||
) as Partial<AiIdleCheckConfig>;
|
||||
this.config = { ...this.config, ...filteredConfig };
|
||||
if (config.enabled === false) {
|
||||
this._disabledReason = 'Disabled by config';
|
||||
this._status = 'disabled';
|
||||
} else if (config.enabled === true && this._status === 'disabled') {
|
||||
this._disabledReason = null;
|
||||
this._status = 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): AiIdleCheckConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
// ========== Test Helpers ==========
|
||||
|
||||
forceCooldown(durationMs: number): void {
|
||||
this.startCooldown(durationMs);
|
||||
}
|
||||
|
||||
forceDisabled(reason: string = 'Forced disabled for testing'): void {
|
||||
this._status = 'disabled';
|
||||
this._disabledReason = reason;
|
||||
this.emit('disabled', reason);
|
||||
}
|
||||
|
||||
forceReady(): void {
|
||||
this.clearCooldown();
|
||||
this._status = 'ready';
|
||||
this._disabledReason = null;
|
||||
}
|
||||
|
||||
private startCooldown(durationMs: number): void {
|
||||
this.clearCooldown();
|
||||
this._cooldownEndsAt = Date.now() + durationMs;
|
||||
this._status = 'cooldown';
|
||||
this.emit('cooldownStarted', this._cooldownEndsAt);
|
||||
|
||||
this.cooldownTimer = setTimeout(() => {
|
||||
this._cooldownEndsAt = null;
|
||||
this._status = 'ready';
|
||||
this.emit('cooldownEnded');
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
private clearCooldown(): void {
|
||||
if (this.cooldownTimer) {
|
||||
clearTimeout(this.cooldownTimer);
|
||||
this.cooldownTimer = null;
|
||||
}
|
||||
this._cooldownEndsAt = null;
|
||||
if (this._status === 'cooldown') {
|
||||
this._status = 'ready';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== MockAiPlanChecker ==========
|
||||
|
||||
/**
|
||||
* Configuration for MockAiPlanChecker behavior.
|
||||
*/
|
||||
export interface MockAiPlanCheckerOptions {
|
||||
/** Default verdict to return */
|
||||
defaultVerdict?: AiPlanCheckVerdict;
|
||||
/** Default reasoning to include */
|
||||
defaultReasoning?: string;
|
||||
/** Simulated check duration in ms */
|
||||
checkDurationMs?: number;
|
||||
/** Whether to start in disabled state */
|
||||
startDisabled?: boolean;
|
||||
/** Reason if starting disabled */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock AI plan checker for testing without spawning real Claude CLI.
|
||||
* Allows configuring verdicts and simulating various states.
|
||||
*/
|
||||
export class MockAiPlanChecker extends EventEmitter {
|
||||
private config: AiPlanCheckConfig;
|
||||
private _status: AiPlanCheckStatus = 'ready';
|
||||
private _lastVerdict: AiPlanCheckVerdict | null = null;
|
||||
private _lastReasoning: string | null = null;
|
||||
private _lastCheckDurationMs: number | null = null;
|
||||
private _cooldownEndsAt: number | null = null;
|
||||
private _consecutiveErrors: number = 0;
|
||||
private _totalChecks: number = 0;
|
||||
private _disabledReason: string | null = null;
|
||||
|
||||
// Queued results for sequential calls
|
||||
private queuedResults: AiPlanCheckResult[] = [];
|
||||
private defaultOptions: MockAiPlanCheckerOptions;
|
||||
private cooldownTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
public sessionId: string,
|
||||
config: Partial<AiPlanCheckConfig> = {},
|
||||
options: MockAiPlanCheckerOptions = {},
|
||||
) {
|
||||
super();
|
||||
this.config = {
|
||||
enabled: true,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
maxContextChars: 8000,
|
||||
checkTimeoutMs: 60000,
|
||||
cooldownMs: 30000,
|
||||
errorCooldownMs: 30000,
|
||||
maxConsecutiveErrors: 3,
|
||||
...config,
|
||||
};
|
||||
this.defaultOptions = {
|
||||
defaultVerdict: 'PLAN_MODE',
|
||||
defaultReasoning: 'Mock verdict',
|
||||
checkDurationMs: 100,
|
||||
startDisabled: false,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (this.defaultOptions.startDisabled) {
|
||||
this._status = 'disabled';
|
||||
this._disabledReason = this.defaultOptions.disabledReason || 'Disabled for testing';
|
||||
}
|
||||
}
|
||||
|
||||
get status(): AiPlanCheckStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
getState(): AiPlanCheckState {
|
||||
return {
|
||||
status: this._status,
|
||||
lastVerdict: this._lastVerdict,
|
||||
lastReasoning: this._lastReasoning,
|
||||
lastCheckDurationMs: this._lastCheckDurationMs,
|
||||
cooldownEndsAt: this._cooldownEndsAt,
|
||||
consecutiveErrors: this._consecutiveErrors,
|
||||
totalChecks: this._totalChecks,
|
||||
disabledReason: this._disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
isOnCooldown(): boolean {
|
||||
return this._cooldownEndsAt !== null && Date.now() < this._cooldownEndsAt;
|
||||
}
|
||||
|
||||
getCooldownRemainingMs(): number {
|
||||
if (this._cooldownEndsAt === null) return 0;
|
||||
return Math.max(0, this._cooldownEndsAt - Date.now());
|
||||
}
|
||||
|
||||
queueResult(result: AiPlanCheckResult): void {
|
||||
this.queuedResults.push(result);
|
||||
}
|
||||
|
||||
queueResults(...results: AiPlanCheckResult[]): void {
|
||||
this.queuedResults.push(...results);
|
||||
}
|
||||
|
||||
setNextPlanMode(reasoning: string = 'Mock: PLAN_MODE'): void {
|
||||
this.queueResult({
|
||||
verdict: 'PLAN_MODE',
|
||||
reasoning,
|
||||
durationMs: this.defaultOptions.checkDurationMs || 100,
|
||||
});
|
||||
}
|
||||
|
||||
setNextNotPlanMode(reasoning: string = 'Mock: NOT_PLAN_MODE'): void {
|
||||
this.queueResult({
|
||||
verdict: 'NOT_PLAN_MODE',
|
||||
reasoning,
|
||||
durationMs: this.defaultOptions.checkDurationMs || 100,
|
||||
});
|
||||
}
|
||||
|
||||
setNextError(reasoning: string = 'Mock: ERROR'): void {
|
||||
this.queueResult({
|
||||
verdict: 'ERROR',
|
||||
reasoning,
|
||||
durationMs: this.defaultOptions.checkDurationMs || 100,
|
||||
});
|
||||
}
|
||||
|
||||
async check(_terminalBuffer: string): Promise<AiPlanCheckResult> {
|
||||
if (this._status === 'disabled') {
|
||||
return { verdict: 'ERROR', reasoning: `Disabled: ${this._disabledReason}`, durationMs: 0 };
|
||||
}
|
||||
|
||||
if (this.isOnCooldown()) {
|
||||
return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 };
|
||||
}
|
||||
|
||||
if (this._status === 'checking') {
|
||||
return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 };
|
||||
}
|
||||
|
||||
this._status = 'checking';
|
||||
this._totalChecks++;
|
||||
this.emit('checkStarted');
|
||||
|
||||
// Simulate async check
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Get result from queue or use default
|
||||
const result = this.queuedResults.shift() || {
|
||||
verdict: this.defaultOptions.defaultVerdict!,
|
||||
reasoning: this.defaultOptions.defaultReasoning!,
|
||||
durationMs: this.defaultOptions.checkDurationMs!,
|
||||
};
|
||||
|
||||
this._lastVerdict = result.verdict;
|
||||
this._lastReasoning = result.reasoning;
|
||||
this._lastCheckDurationMs = result.durationMs;
|
||||
|
||||
if (result.verdict === 'PLAN_MODE') {
|
||||
this._consecutiveErrors = 0;
|
||||
this._status = 'ready';
|
||||
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
||||
this._consecutiveErrors = 0;
|
||||
this.startCooldown(this.config.cooldownMs);
|
||||
} else {
|
||||
this._consecutiveErrors++;
|
||||
if (this._consecutiveErrors >= this.config.maxConsecutiveErrors) {
|
||||
this._status = 'disabled';
|
||||
this._disabledReason = `${this.config.maxConsecutiveErrors} consecutive errors`;
|
||||
this.emit('disabled', this._disabledReason);
|
||||
} else {
|
||||
this.startCooldown(this.config.errorCooldownMs);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('checkCompleted', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this._status === 'checking') {
|
||||
this._status = 'ready';
|
||||
this.emit('log', '[MockAiPlanChecker] Check cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cancel();
|
||||
this.clearCooldown();
|
||||
this._lastVerdict = null;
|
||||
this._lastReasoning = null;
|
||||
this._lastCheckDurationMs = null;
|
||||
this._consecutiveErrors = 0;
|
||||
this.queuedResults = [];
|
||||
this._status = this._disabledReason ? 'disabled' : 'ready';
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<AiPlanCheckConfig>): void {
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined),
|
||||
) as Partial<AiPlanCheckConfig>;
|
||||
this.config = { ...this.config, ...filteredConfig };
|
||||
if (config.enabled === false) {
|
||||
this._disabledReason = 'Disabled by config';
|
||||
this._status = 'disabled';
|
||||
} else if (config.enabled === true && this._status === 'disabled') {
|
||||
this._disabledReason = null;
|
||||
this._status = 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): AiPlanCheckConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
// ========== Test Helpers ==========
|
||||
|
||||
forceCooldown(durationMs: number): void {
|
||||
this.startCooldown(durationMs);
|
||||
}
|
||||
|
||||
forceDisabled(reason: string = 'Forced disabled for testing'): void {
|
||||
this._status = 'disabled';
|
||||
this._disabledReason = reason;
|
||||
this.emit('disabled', reason);
|
||||
}
|
||||
|
||||
forceReady(): void {
|
||||
this.clearCooldown();
|
||||
this._status = 'ready';
|
||||
this._disabledReason = null;
|
||||
}
|
||||
|
||||
private startCooldown(durationMs: number): void {
|
||||
this.clearCooldown();
|
||||
this._cooldownEndsAt = Date.now() + durationMs;
|
||||
this._status = 'cooldown';
|
||||
this.emit('cooldownStarted', this._cooldownEndsAt);
|
||||
|
||||
this.cooldownTimer = setTimeout(() => {
|
||||
this._cooldownEndsAt = null;
|
||||
this._status = 'ready';
|
||||
this.emit('cooldownEnded');
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
private clearCooldown(): void {
|
||||
if (this.cooldownTimer) {
|
||||
clearTimeout(this.cooldownTimer);
|
||||
this.cooldownTimer = null;
|
||||
}
|
||||
this._cooldownEndsAt = null;
|
||||
if (this._status === 'cooldown') {
|
||||
this._status = 'ready';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== State Transition Helpers ==========
|
||||
|
||||
/**
|
||||
* State transition event for tracking.
|
||||
*/
|
||||
export interface StateTransition {
|
||||
from: RespawnState;
|
||||
to: RespawnState;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a state tracker that records all state transitions.
|
||||
* Attach to a RespawnController via controller.on('stateChanged', tracker.record).
|
||||
*/
|
||||
export function createStateTracker() {
|
||||
const transitions: StateTransition[] = [];
|
||||
|
||||
return {
|
||||
record(to: RespawnState, from: RespawnState): void {
|
||||
transitions.push({ from, to, timestamp: Date.now() });
|
||||
},
|
||||
getTransitions(): StateTransition[] {
|
||||
return [...transitions];
|
||||
},
|
||||
getStates(): RespawnState[] {
|
||||
return transitions.map((t) => t.to);
|
||||
},
|
||||
hasVisited(state: RespawnState): boolean {
|
||||
return transitions.some((t) => t.to === state);
|
||||
},
|
||||
hasTransition(from: RespawnState, to: RespawnState): boolean {
|
||||
return transitions.some((t) => t.from === from && t.to === to);
|
||||
},
|
||||
getCurrentState(): RespawnState | undefined {
|
||||
return transitions.length > 0 ? transitions[transitions.length - 1].to : undefined;
|
||||
},
|
||||
clear(): void {
|
||||
transitions.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event recorder for tracking all events from a RespawnController.
|
||||
*/
|
||||
export function createEventRecorder() {
|
||||
const events: Array<{ type: string; args: unknown[]; timestamp: number }> = [];
|
||||
|
||||
return {
|
||||
handler(type: string): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
events.push({ type, args, timestamp: Date.now() });
|
||||
};
|
||||
},
|
||||
getEvents(): Array<{ type: string; args: unknown[]; timestamp: number }> {
|
||||
return [...events];
|
||||
},
|
||||
getEventsOfType(type: string): Array<{ type: string; args: unknown[]; timestamp: number }> {
|
||||
return events.filter((e) => e.type === type);
|
||||
},
|
||||
hasEvent(type: string): boolean {
|
||||
return events.some((e) => e.type === type);
|
||||
},
|
||||
countEvents(type: string): number {
|
||||
return events.filter((e) => e.type === type).length;
|
||||
},
|
||||
clear(): void {
|
||||
events.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Factory Functions ==========
|
||||
|
||||
/**
|
||||
* Default fast test configuration.
|
||||
* Uses short timeouts for faster test execution.
|
||||
*/
|
||||
export const FAST_TEST_CONFIG: Partial<RespawnConfig> = {
|
||||
idleTimeoutMs: 50,
|
||||
interStepDelayMs: 20,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 300,
|
||||
autoAcceptDelayMs: 100,
|
||||
aiIdleCheckEnabled: false,
|
||||
aiPlanCheckEnabled: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration with AI checks enabled but short timeouts.
|
||||
*/
|
||||
export const AI_ENABLED_TEST_CONFIG: Partial<RespawnConfig> = {
|
||||
...FAST_TEST_CONFIG,
|
||||
aiIdleCheckEnabled: true,
|
||||
aiIdleCheckTimeoutMs: 500,
|
||||
aiIdleCheckCooldownMs: 200,
|
||||
aiPlanCheckEnabled: true,
|
||||
aiPlanCheckTimeoutMs: 500,
|
||||
aiPlanCheckCooldownMs: 200,
|
||||
};
|
||||
|
||||
// ========== Test Assertion Helpers ==========
|
||||
|
||||
/**
|
||||
* Wait for a specific state to be reached.
|
||||
* Useful for async state transition testing.
|
||||
*/
|
||||
export async function waitForState(
|
||||
controller: {
|
||||
state: RespawnState;
|
||||
on: (event: string, handler: (state: RespawnState) => void) => void;
|
||||
},
|
||||
targetState: RespawnState,
|
||||
timeoutMs: number = 1000,
|
||||
): Promise<void> {
|
||||
if (controller.state === targetState) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timeout waiting for state ${targetState}, current: ${controller.state}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = (state: RespawnState) => {
|
||||
if (state === targetState) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
controller.on('stateChanged', handler);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user