mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Full product rename across 109 files (~834 occurrences): - Env vars: CLAUDEMAN_* → CODEMAN_* - Data dirs: ~/.claudeman/ → ~/.codeman/, ~/claudeman-cases/ → ~/codeman-cases/ - tmux prefix: claudeman- → codeman- - localStorage: claudeman-* → codeman-* - Package/CLI: claudeman → codeman - GitHub repo: Ark0N/Claudeman → Ark0N/Codeman - systemd service: claudeman-web → codeman-web - Class: ClaudemanApp → CodemanApp Migration infrastructure for seamless transition: - state-store.ts: auto-migrates data directories on startup - tmux-manager.ts: dual-prefix detection (legacy claudeman- sessions) - app.js: localStorage key migration (preserves old keys) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
396 lines
15 KiB
JavaScript
396 lines
15 KiB
JavaScript
/**
|
||
* 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); });
|