fix: make localEchoEnabled per-platform so desktop/mobile settings don't overwrite each other

Strip localEchoEnabled from server PUT payload (stays in device-specific
localStorage only). Add to displayKeys as safety net for stale server values.

chore: bump version to 0.1588
This commit is contained in:
arkon
2026-02-21 23:37:29 +01:00
parent 6c75432d74
commit c820acc96e
8 changed files with 2786 additions and 45 deletions
+395
View File
@@ -0,0 +1,395 @@
/**
* Local Echo Overlay — Daily use scenarios test.
*
* Simulates real mobile usage:
* 1. Type text → switch tabs → switch back → verify state
* 2. Type → change mind → delete all → type something else
* 3. Type → reload page → verify recovery
* 4. Rapidly type-delete-type (simulate editing)
* 5. Type on Tab A → switch B → type on B → switch A → verify both
* 6. Type → Enter → wait for response → type again
* 7. Partial delete then add more text
*
* Usage: node test/local-echo-daily-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-daily';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed: ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const ov = app._localEchoOverlay;
return {
visible: ov?.overlay?.style.display !== 'none',
text: ov?.pendingText || '',
flushed: ov?._flushedOffset || 0,
flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
enabled: app._localEchoEnabled,
activeId: app.activeSessionId,
children: (() => {
if (!ov?.overlay) return [];
return Array.from(ov.overlay.children).map(c => ({
text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''),
left: c.style.left, top: c.style.top,
}));
})(),
};
});
}
async function focus(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function switchTab(page, id) {
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id);
await page.waitForTimeout(1500);
}
async function sc(page, name) {
const p = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path: p });
console.log(` 📸 ${p}`);
}
const R = [];
const pass = (n) => { R.push({n, s:'PASS'}); console.log(` ✅ ${n}`); };
const fail = (n, d) => { R.push({n, s:'FAIL', d}); console.log(` ❌ ${n} — ${d}`); };
const info = (n, d) => { R.push({n, s:'INFO', d}); console.log(` ℹ️ ${n} — ${d}`); };
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Daily Use Tests ===\n');
const sA = await createSession('daily-A');
const sB = await createSession('daily-B');
console.log(` A: ${sA}\n B: ${sB}`);
if (!await waitForReady(sA) || !await waitForReady(sB)) {
await deleteSession(sA); await deleteSession(sB);
throw new Error('Sessions not ready');
}
console.log(' Ready\n');
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('claudeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('claudeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
await switchTab(page, sA);
await focus(page);
// ══════════════════════════════════════════
// 1. Type → switch tab → switch back
// ══════════════════════════════════════════
console.log('--- 1. Type → Tab switch → Return ---');
await page.keyboard.type('fix the login bug', { delay: 40 });
await page.waitForTimeout(300);
let st = await getState(page);
await sc(page, '01-typed');
console.log(` Typed: "${st?.text}"`);
if (st?.text === 'fix the login bug') pass('Text buffered');
else fail('Buffer', `"${st?.text}"`);
// Switch to B
await switchTab(page, sB);
st = await getState(page);
console.log(` After switch to B: text="${st?.text}", flushed A=${st?.flushedMap?.[sA]}`);
await sc(page, '02-on-tab-b');
if (!st?.text) pass('Overlay cleared on switch');
else fail('Clear on switch', `"${st?.text}"`);
// Switch back to A
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, '03-back-on-a');
console.log(` Back on A: text="${st?.text}", flushed=${st?.flushed}`);
if (!st?.text && st?.flushed > 0) pass('Flushed text preserved on return');
else info('Return state', `text="${st?.text}", flushed=${st?.flushed}`);
// Type more
await page.keyboard.type(' please', { delay: 40 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '04-type-more-on-a');
console.log(` After adding: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.text === ' please' && st?.flushed > 0) pass('Can type after return with flushed offset');
else fail('Type after return', `text="${st?.text}", flushed=${st?.flushed}`);
// Submit
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
// ══════════════════════════════════════════
// 2. Type → delete all → retype
// ══════════════════════════════════════════
console.log('\n--- 2. Type → delete all → retype ---');
await focus(page);
await page.keyboard.type('wrong command oops', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
console.log(` Typed wrong: "${st?.text}"`);
// Delete all
for (let i = 0; i < 'wrong command oops'.length + 2; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(20);
}
await page.waitForTimeout(200);
st = await getState(page);
console.log(` After delete: text="${st?.text}", visible=${st?.visible}`);
if (!st?.text && !st?.visible) pass('Fully deleted');
else fail('Full delete', `"${st?.text}"`);
// Retype correct command
await page.keyboard.type('say hello', { delay: 40 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '05-retyped');
console.log(` Retyped: "${st?.text}"`);
if (st?.text === 'say hello' && st?.visible) pass('Retype after delete works');
else fail('Retype', `"${st?.text}", visible=${st?.visible}`);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
// ══════════════════════════════════════════
// 3. Type → reload page → check recovery
// ══════════════════════════════════════════
console.log('\n--- 3. Type → reload page → recovery ---');
await focus(page);
await page.keyboard.type('unsent text before reload', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` Before reload: "${st?.text}"`);
await sc(page, '06-before-reload');
// Reload the page
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(3000); // Wait for SSE reconnect + buffer load
st = await getState(page);
await sc(page, '07-after-reload');
console.log(` After reload: text="${st?.text}", enabled=${st?.enabled}`);
// localStorage persistence was removed — stale text from previous page
// loads caused ghost-merging with new input, breaking editing.
// After reload, overlay should be clean (no stale text).
if (st?.text === '') {
pass('Overlay clean after reload (no stale text)');
} else {
fail('Stale text after reload', `text="${st?.text}"`);
}
// ══════════════════════════════════════════
// 4. Rapid type-delete-type (simulate editing)
// ══════════════════════════════════════════
console.log('\n--- 4. Rapid type-delete-type editing ---');
await focus(page);
// Type "hello", delete "lo", type "p me"
await page.keyboard.type('hello', { delay: 20 });
await page.waitForTimeout(100);
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
await page.keyboard.type('p me', { delay: 20 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '08-rapid-edit');
console.log(` After rapid edit: "${st?.text}"`);
if (st?.text === 'help me') pass('Rapid edit produces correct text');
else fail('Rapid edit', `expected "help me", got "${st?.text}"`);
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// 5. Interleaved typing on two tabs
// ══════════════════════════════════════════
console.log('\n--- 5. Interleaved typing on two tabs ---');
await focus(page);
await page.keyboard.type('tab A input', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, '09-tab-a-input');
st = await getState(page);
console.log(` Tab A: "${st?.text}"`);
if (st?.text === 'tab A input') pass('Tab A text correct');
else fail('Tab A text', `"${st?.text}"`);
// Switch to B and type
await switchTab(page, sB);
await focus(page);
await page.keyboard.type('tab B input', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, '10-tab-b-input');
st = await getState(page);
console.log(` Tab B: "${st?.text}"`);
if (st?.text === 'tab B input') pass('Tab B text correct');
else fail('Tab B text', `"${st?.text}"`);
// Switch back to A — A's text was flushed
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, '11-back-to-a');
console.log(` Back to A: text="${st?.text}", flushed=${st?.flushed}, flushedMap A=${st?.flushedMap?.[sA]}, B=${st?.flushedMap?.[sB]}`);
// A's input was flushed (11 chars), B's was flushed (11 chars)
if (st?.flushed > 0) pass('Tab A flushed offset restored');
else info('Tab A flushed', `offset=${st?.flushed}`);
// Switch to B — B's input was flushed
await switchTab(page, sB);
await focus(page);
st = await getState(page);
await sc(page, '12-back-to-b');
console.log(` Back to B: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.flushed > 0) pass('Tab B flushed offset restored');
else info('Tab B flushed', `offset=${st?.flushed}`);
// Submit both with Enter
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
await switchTab(page, sA);
await focus(page);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// ══════════════════════════════════════════
// 6. Type → Enter → response → type again
// ══════════════════════════════════════════
console.log('\n--- 6. Type → Enter → wait → type again ---');
await focus(page);
await page.keyboard.type('just say ok', { delay: 30 });
await page.keyboard.press('Enter');
await page.waitForTimeout(4000); // Wait for response
await focus(page);
await page.keyboard.type('now say goodbye', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '13-second-prompt');
console.log(` Second prompt: "${st?.text}", flushed=${st?.flushed}`);
if (st?.text === 'now say goodbye' && st?.flushed === 0) {
pass('Clean typing on new prompt after response');
} else {
fail('Second prompt', `text="${st?.text}", flushed=${st?.flushed}`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// 7. Partial delete then add text
// ══════════════════════════════════════════
console.log('\n--- 7. Partial delete + add ---');
await focus(page);
await page.keyboard.type('fixing the bug', { delay: 30 });
await page.waitForTimeout(200);
// Delete "bug" (3 chars), keeping the space
for (let i = 0; i < 3; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
// Add "typo"
await page.keyboard.type('typo', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '14-partial-edit');
console.log(` After partial edit: "${st?.text}"`);
if (st?.text === 'fixing the typo') pass('Partial delete + add correct');
else fail('Partial edit', `expected "fixing the typo", got "${st?.text}"`);
// Final
await page.keyboard.press('Control+c');
await page.waitForTimeout(300);
await sc(page, '15-final');
// ══════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════
console.log('\n=== SUMMARY ===');
const p = R.filter(r => r.s === 'PASS').length;
const f = R.filter(r => r.s === 'FAIL').length;
const i = R.filter(r => r.s === 'INFO').length;
console.log(` ${p} passed, ${f} failed, ${i} info`);
if (f > 0) {
console.log('\n FAILURES:');
R.filter(r => r.s === 'FAIL').forEach(r => console.log(` ❌ ${r.n}: ${r.d}`));
}
} finally {
await browser.close();
console.log('\nCleaning up...');
await deleteSession(sA);
await deleteSession(sB);
}
}
main().catch(err => { console.error('Test failed:', err); process.exit(1); });