diff --git a/CLAUDE.md b/CLAUDE.md index d910fd0c..f9a69a03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1580 (must match `package.json` for npm publish) +**Version**: 0.1581 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index ce8857c7..9404b0dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1580", + "version": "0.1581", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/session.ts b/src/session.ts index 862f75cc..60f75752 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2001,16 +2001,22 @@ export class Session extends EventEmitter { return false; } + /** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */ + private _ptyCols = 120; + private _ptyRows = 40; + /** * Resizes the PTY terminal dimensions. - * - * Call this when the frontend terminal is resized to keep PTY in sync. + * Skips the resize if dimensions haven't changed to avoid triggering + * unnecessary Ink full-screen redraws (visible flicker on tab switch). * * @param cols - Number of columns (width in characters) * @param rows - Number of rows (height in lines) */ resize(cols: number, rows: number): void { - if (this.ptyProcess) { + if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) { + this._ptyCols = cols; + this._ptyRows = rows; this.ptyProcess.resize(cols, rows); } } diff --git a/src/web/public/app.js b/src/web/public/app.js index a679ff94..049751fc 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -970,7 +970,7 @@ class LocalEchoOverlay { // Create a styled line div with opaque background _makeLine(text, leftPx, topPx, widthPx, cellH) { const el = document.createElement('div'); - el.style.cssText = `position:absolute;white-space:pre;font-kerning:none;overflow:hidden;pointer-events:none`; + el.style.cssText = `position:absolute;white-space:pre;font-kerning:none;pointer-events:none`; el.style.fontFamily = this._fontFamily; el.style.fontSize = this._fontSize; el.style.fontWeight = this._fontWeight; @@ -980,7 +980,7 @@ class LocalEchoOverlay { el.style.left = leftPx + 'px'; el.style.top = topPx + 'px'; el.style.width = widthPx + 'px'; - el.style.height = cellH + 'px'; + el.style.height = (cellH + 1) + 'px'; el.style.lineHeight = cellH + 'px'; el.textContent = text; return el; @@ -1030,7 +1030,6 @@ class LocalEchoOverlay { // Position overlay container at prompt row this.overlay.style.left = '0px'; this.overlay.style.top = (activePrompt.row * cellH) + 'px'; - this.overlay.style.boxShadow = 'none'; // Build line elements — recreate on each render (typically 1-3 divs, negligible cost) this.overlay.innerHTML = ''; @@ -1040,8 +1039,6 @@ class LocalEchoOverlay { const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx; const topPx = i * cellH; const lineEl = this._makeLine(lines[i], leftPx, topPx, widthPx, cellH); - // Cover subpixel seam above first line only - if (i === 0) lineEl.style.boxShadow = `0 -1px 0 0 ${this._bg}`; this.overlay.appendChild(lineEl); } @@ -2852,8 +2849,8 @@ class ClaudemanApp { await this.chunkedTerminalWrite(cleanBuffer); } - // Send resize to ensure proper dimensions - await this.sendResize(data.id); + // Fire-and-forget resize — don't block on it + this.sendResize(data.id); // Re-position local echo overlay at new prompt location this._localEchoOverlay?.rerender(); } catch (err) { @@ -2911,7 +2908,6 @@ class ClaudemanApp { addListener('session:idle', (e) => { const data = JSON.parse(e.data); - console.log('[DEBUG] session:idle event for:', data.id); const session = this.sessions.get(data.id); if (session) { session.status = 'idle'; @@ -2940,7 +2936,6 @@ class ClaudemanApp { addListener('session:working', (e) => { const data = JSON.parse(e.data); - console.log('[DEBUG] session:working event for:', data.id); const session = this.sessions.get(data.id); if (session) { session.status = 'busy'; @@ -4719,18 +4714,24 @@ class ClaudemanApp { if (selectGen !== this._selectGeneration) return; // stale — newer selectSession won const data = await res.json(); - this.terminal.clear(); - this.terminal.reset(); if (data.terminalBuffer) { - // Show truncation indicator if buffer was cut - if (data.truncated) { - this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n'); + // Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash. + // On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is + // very visible, causing the terminal to flash blank then repaint. + const needsRewrite = data.terminalBuffer !== cachedBuffer; + if (needsRewrite) { + this.terminal.clear(); + this.terminal.reset(); + // Show truncation indicator if buffer was cut + if (data.truncated) { + this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n'); + } + // Use chunked write for large buffers to avoid UI jank + await this.chunkedTerminalWrite(data.terminalBuffer); + if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates + // Ensure terminal is scrolled to bottom after buffer load + this.terminal.scrollToBottom(); } - // Use chunked write for large buffers to avoid UI jank - await this.chunkedTerminalWrite(data.terminalBuffer); - if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates - // Ensure terminal is scrolled to bottom after buffer load - this.terminal.scrollToBottom(); // Update cache (cap at 20 entries) this.terminalBufferCache.set(sessionId, data.terminalBuffer); @@ -4739,6 +4740,10 @@ class ClaudemanApp { const oldest = this.terminalBufferCache.keys().next().value; this.terminalBufferCache.delete(oldest); } + } else if (!cachedBuffer) { + // No fresh buffer and no cache — clear any stale content + this.terminal.clear(); + this.terminal.reset(); } // Restore local echo text for incoming session (saved during previous tab switch). @@ -4757,8 +4762,9 @@ class ClaudemanApp { } } - // Send resize and Ctrl+L to trigger Claude to redraw at correct size - await this.sendResize(sessionId); + // Fire-and-forget resize — don't await to avoid blocking UI. + // The resize triggers an Ink redraw in Claude which streams back via SSE. + this.sendResize(sessionId); // Defer secondary panel updates so they don't block the main thread // after terminal content is already visible. diff --git a/test/perf-browser.test.ts b/test/perf-browser.test.ts new file mode 100644 index 00000000..0cdf6b5b --- /dev/null +++ b/test/perf-browser.test.ts @@ -0,0 +1,977 @@ +/** + * Browser performance test suite + * + * Tests real browser interactions: page load, tab creation/switching, + * terminal input latency, subagent window management, settings modal, + * SSE connection speed, and rendering under load. + * + * Port: 3210 (perf-browser tests) + * + * Run: npx vitest run test/perf-browser.test.ts + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PORT = 3210; +const BASE_URL = `http://localhost:${PORT}`; + +// Thresholds (ms) +const THRESHOLDS = { + PAGE_LOAD: 3000, // Full page load including JS init + DOMContentLoaded: 1500, // HTML parsed + SSE_CONNECT: 2000, // SSE EventSource open + TAB_CREATE_API: 200, // POST /api/sessions response + TAB_RENDER: 300, // Tab element appears in DOM + TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation) + TERMINAL_INIT: 500, // xterm.js instance created for tab + INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged + SETTINGS_OPEN: 300, // Settings modal visible + SETTINGS_CLOSE: 200, // Settings modal hidden + SESSION_OPTIONS_OPEN: 300, // Session options modal visible + SESSION_OPTIONS_TAB: 200, // Modal tab switch + SUBAGENT_WINDOW_OPEN: 400, // Subagent window rendered + SUBAGENT_WINDOW_CLOSE: 200, + BULK_TAB_CREATE: 3000, // Create 10 sessions + BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads) + MEMORY_HEAP_MB: 200, // Max JS heap after heavy load + BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer +}; + +let server: WebServer; +let browser: Browser; + +// ─── Helpers ────────────────────────────────────────────── + +async function freshPage(): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + }); + const page = await context.newPage(); + return { context, page }; +} + +async function navigateAndWait(page: Page): Promise { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + // Wait for app.js to initialize (app-loaded class added to body) + await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { + timeout: 5000, + }); +} + +/** Create a session via API, return its ID */ +async function createSession(page: Page, name: string): Promise { + const result = await page.evaluate(async (n: string) => { + const res = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: '/tmp', name: n }), + }); + const data = await res.json(); + return data.id ?? data.session?.id; + }, name); + return result as string; +} + +/** Delete a session via API */ +async function deleteSession(page: Page, id: string): Promise { + await page.evaluate(async (sid: string) => { + await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); + }, id); +} + +/** Measure time for a function to complete */ +async function measure(fn: () => Promise): Promise { + const start = performance.now(); + await fn(); + return performance.now() - start; +} + +/** Get JS heap size in MB (Chromium only) */ +async function getHeapMB(page: Page): Promise { + const metrics = await page.evaluate(() => { + // @ts-expect-error — Chromium-only API + return (performance as { memory?: { usedJSHeapSize: number } }).memory?.usedJSHeapSize; + }); + return metrics ? metrics / (1024 * 1024) : 0; +} + +// ─── Setup / Teardown ───────────────────────────────────── + +beforeAll(async () => { + server = new WebServer(PORT, false, true); // testMode + await server.start(); + browser = await chromium.launch({ headless: true }); +}, 30_000); + +afterAll(async () => { + await browser?.close(); + await server?.stop(); +}, 30_000); + +// ─── Tests ──────────────────────────────────────────────── + +describe('Page load performance', () => { + let context: BrowserContext; + let page: Page; + + afterAll(async () => { + await context?.close(); + }); + + it('DOMContentLoaded fires within threshold', async () => { + ({ context, page } = await freshPage()); + + const timing = await measure(async () => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + }); + + console.log(`[page load] DOMContentLoaded: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.DOMContentLoaded); + }); + + it('full app initialization completes within threshold', async () => { + ({ context, page } = await freshPage()); + + const timing = await measure(async () => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { + timeout: 5000, + }); + }); + + console.log(`[page load] Full init: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.PAGE_LOAD); + }); + + it('SSE EventSource connects within threshold', async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + const timing = await measure(async () => { + await page.waitForFunction( + () => { + const indicator = document.getElementById('connectionIndicator'); + if (!indicator) return false; + const dot = document.getElementById('connectionDot'); + return dot?.classList.contains('connected') || indicator.style.display === 'none'; + }, + { timeout: 5000 }, + ); + }); + + console.log(`[SSE] connection established: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.SSE_CONNECT); + }); + + it('loading skeleton disappears after init', async () => { + ({ context, page } = await freshPage()); + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + + // Skeleton should be visible initially + const skeletonVisible = await page.locator('.loading-skeleton').isVisible(); + // May already be hidden if JS executed fast — that's fine + + await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { + timeout: 5000, + }); + + // After init, skeleton should be hidden + const skeletonHidden = await page.evaluate(() => { + const el = document.querySelector('.loading-skeleton') as HTMLElement; + return el ? getComputedStyle(el).display === 'none' : true; + }); + expect(skeletonHidden).toBe(true); + }); +}); + +describe('Session tab creation', () => { + let context: BrowserContext; + let page: Page; + const sessionIds: string[] = []; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + }); + + afterAll(async () => { + // Clean up sessions + for (const id of sessionIds) { + await deleteSession(page, id).catch(() => {}); + } + await context?.close(); + }); + + it('API responds within threshold', async () => { + let sessionId: string | undefined; + const timing = await measure(async () => { + sessionId = await createSession(page, 'perf-api-test'); + }); + if (sessionId) sessionIds.push(sessionId); + + console.log(`[tab create] API response: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.TAB_CREATE_API); + }); + + it('tab element renders in DOM within threshold', async () => { + const tabCountBefore = await page.locator('.session-tab').count(); + + const id = await createSession(page, 'perf-render-test'); + sessionIds.push(id); + + const timing = await measure(async () => { + await page.waitForFunction( + (expected: number) => document.querySelectorAll('.session-tab').length > expected, + tabCountBefore, + { timeout: 3000 }, + ); + }); + + console.log(`[tab create] DOM render: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.TAB_RENDER); + }); + + it('terminal container initializes after selecting tab', async () => { + const id = await createSession(page, 'perf-terminal-test'); + sessionIds.push(id); + + // Wait for tab to appear + await page.waitForSelector(`.session-tab[data-id="${id}"]`, { timeout: 3000 }); + + const timing = await measure(async () => { + // Click the tab + await page.locator(`.session-tab[data-id="${id}"]`).click(); + // Wait for xterm.js to init (canvas or rows appear) + await page.waitForFunction( + () => { + const container = document.getElementById('terminalContainer'); + if (!container) return false; + return container.querySelector('.xterm-screen') !== null; + }, + { timeout: 3000 }, + ); + }); + + console.log(`[tab create] terminal init: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.TERMINAL_INIT); + }); +}); + +describe('Tab switching performance', () => { + let context: BrowserContext; + let page: Page; + const sessionIds: string[] = []; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + // Create 5 sessions + for (let i = 0; i < 5; i++) { + const id = await createSession(page, `perf-switch-${i}`); + sessionIds.push(id); + } + // Wait for all tabs to render + await page.waitForFunction( + (count: number) => document.querySelectorAll('.session-tab').length >= count, + sessionIds.length, + { timeout: 5000 }, + ); + // Select first tab + await page.locator(`.session-tab[data-id="${sessionIds[0]}"]`).click(); + await page.waitForTimeout(500); // Let terminal fully init + }); + + afterAll(async () => { + for (const id of sessionIds) { + await deleteSession(page, id).catch(() => {}); + } + await context?.close(); + }); + + it('switches between 2 tabs within threshold', async () => { + const timings: number[] = []; + + for (let i = 0; i < 4; i++) { + const targetIdx = i % 2 === 0 ? 1 : 0; + const targetId = sessionIds[targetIdx]; + + const timing = await measure(async () => { + await page.locator(`.session-tab[data-id="${targetId}"]`).click(); + await page.waitForFunction( + (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'), + targetId, + { timeout: 2000 }, + ); + }); + timings.push(timing); + } + + const avg = timings.reduce((a, b) => a + b, 0) / timings.length; + const max = Math.max(...timings); + console.log(`[tab switch] avg: ${avg.toFixed(0)}ms, max: ${max.toFixed(0)}ms (${timings.length} switches)`); + expect(avg).toBeLessThan(THRESHOLDS.TAB_SWITCH); + }); + + it('cycles through all 5 tabs efficiently', async () => { + const timings: number[] = []; + + for (let i = 0; i < sessionIds.length; i++) { + const targetId = sessionIds[i]; + const timing = await measure(async () => { + await page.locator(`.session-tab[data-id="${targetId}"]`).click(); + await page.waitForFunction( + (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'), + targetId, + { timeout: 2000 }, + ); + }); + timings.push(timing); + } + + const avg = timings.reduce((a, b) => a + b, 0) / timings.length; + const max = Math.max(...timings); + console.log(`[tab cycle] avg: ${avg.toFixed(0)}ms, max: ${max.toFixed(0)}ms (5 tabs)`); + expect(avg).toBeLessThan(THRESHOLDS.TAB_SWITCH); + }); +}); + +describe('Bulk tab operations', () => { + let context: BrowserContext; + let page: Page; + const sessionIds: string[] = []; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + }); + + afterAll(async () => { + for (const id of sessionIds) { + await deleteSession(page, id).catch(() => {}); + } + await context?.close(); + }); + + it('creates 10 sessions within threshold', async () => { + const timing = await measure(async () => { + for (let i = 0; i < 10; i++) { + const id = await createSession(page, `perf-bulk-${i}`); + sessionIds.push(id); + } + // Wait for all tabs to render + await page.waitForFunction( + (count: number) => document.querySelectorAll('.session-tab').length >= count, + 10, + { timeout: 5000 }, + ); + }); + + console.log(`[bulk create] 10 sessions: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.BULK_TAB_CREATE); + }); + + it('average tab switch stays fast with 10 tabs', async () => { + const timings: number[] = []; + + // Cycle through all 10 tabs + for (const targetId of sessionIds) { + const timing = await measure(async () => { + await page.locator(`.session-tab[data-id="${targetId}"]`).click(); + await page.waitForFunction( + (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'), + targetId, + { timeout: 2000 }, + ); + }); + timings.push(timing); + } + + const avg = timings.reduce((a, b) => a + b, 0) / timings.length; + const max = Math.max(...timings); + const p95 = timings.sort((a, b) => a - b)[Math.floor(timings.length * 0.95)]; + console.log(`[bulk switch] avg: ${avg.toFixed(0)}ms, p95: ${p95.toFixed(0)}ms, max: ${max.toFixed(0)}ms (10 tabs)`); + expect(avg).toBeLessThan(THRESHOLDS.BULK_TAB_SWITCH_AVG); + }); + + it('rapidly switches tabs without errors', async () => { + // Rapid-fire tab switching — test for race conditions + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + const start = performance.now(); + for (let i = 0; i < 20; i++) { + const targetId = sessionIds[i % sessionIds.length]; + await page.locator(`.session-tab[data-id="${targetId}"]`).click(); + // Minimal wait — stress test + await page.waitForTimeout(30); + } + const elapsed = performance.now() - start; + + console.log(`[rapid switch] 20 switches in ${elapsed.toFixed(0)}ms (${(elapsed / 20).toFixed(0)}ms avg)`); + expect(errors).toHaveLength(0); + }); +}); + +describe('Terminal input performance', () => { + let context: BrowserContext; + let page: Page; + let sessionId: string; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + sessionId = await createSession(page, 'perf-input'); + await page.waitForSelector(`.session-tab[data-id="${sessionId}"]`, { timeout: 3000 }); + await page.locator(`.session-tab[data-id="${sessionId}"]`).click(); + await page.waitForTimeout(500); // Let terminal init + }); + + afterAll(async () => { + await deleteSession(page, sessionId).catch(() => {}); + await context?.close(); + }); + + it('API input endpoint responds quickly', async () => { + const timings: number[] = []; + for (let i = 0; i < 5; i++) { + const timing = await measure(async () => { + await page.evaluate(async (sid: string) => { + await fetch(`/api/sessions/${sid}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: `echo test-${Date.now()}` }), + }); + }, sessionId); + }); + timings.push(timing); + } + + const avg = timings.reduce((a, b) => a + b, 0) / timings.length; + console.log(`[input API] avg: ${avg.toFixed(0)}ms (5 calls)`); + expect(avg).toBeLessThan(THRESHOLDS.INPUT_ROUNDTRIP); + }); + + it('keyboard typing performance via page.keyboard', async () => { + // Focus the terminal + await page.locator('.terminal-container').click(); + await page.waitForTimeout(100); + + const text = 'The quick brown fox jumps over the lazy dog'; + const start = performance.now(); + await page.keyboard.type(text, { delay: 0 }); // No artificial delay + const elapsed = performance.now() - start; + + const msPerChar = elapsed / text.length; + console.log(`[keyboard type] ${text.length} chars in ${elapsed.toFixed(0)}ms (${msPerChar.toFixed(1)}ms/char)`); + // Should be well under 5ms per character + expect(msPerChar).toBeLessThan(10); + }); +}); + +describe('Settings modal performance', () => { + let context: BrowserContext; + let page: Page; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + }); + + afterAll(async () => { + await context?.close(); + }); + + it('opens within threshold', async () => { + const timing = await measure(async () => { + await page.locator('.btn-settings').click(); + await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 }); + }); + + console.log(`[settings] open: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.SETTINGS_OPEN); + }); + + it('closes within threshold', async () => { + // Make sure it's open + const isOpen = await page.locator('#appSettingsModal.active').isVisible().catch(() => false); + if (!isOpen) { + await page.locator('.btn-settings').click(); + await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 }); + } + + const timing = await measure(async () => { + // Press Escape to close + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => !document.querySelector('#appSettingsModal')?.classList.contains('active'), + { timeout: 2000 }, + ); + }); + + console.log(`[settings] close: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.SETTINGS_CLOSE); + }); + + it('repeated open/close cycles stay fast', async () => { + const timings: number[] = []; + + for (let i = 0; i < 5; i++) { + // Open + const openTime = await measure(async () => { + await page.locator('.btn-settings').click(); + await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 }); + }); + // Close + const closeTime = await measure(async () => { + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => !document.querySelector('#appSettingsModal')?.classList.contains('active'), + { timeout: 2000 }, + ); + }); + timings.push(openTime + closeTime); + } + + const avg = timings.reduce((a, b) => a + b, 0) / timings.length; + console.log(`[settings] avg open+close cycle: ${avg.toFixed(0)}ms (5 cycles)`); + // Each cycle should be under open + close thresholds + expect(avg).toBeLessThan(THRESHOLDS.SETTINGS_OPEN + THRESHOLDS.SETTINGS_CLOSE); + }); +}); + +describe('Session options modal performance', () => { + let context: BrowserContext; + let page: Page; + let sessionId: string; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + sessionId = await createSession(page, 'perf-options'); + await page.waitForSelector(`.session-tab[data-id="${sessionId}"]`, { timeout: 3000 }); + await page.locator(`.session-tab[data-id="${sessionId}"]`).click(); + await page.waitForTimeout(300); + }); + + afterAll(async () => { + await deleteSession(page, sessionId).catch(() => {}); + await context?.close(); + }); + + it('opens via gear icon within threshold', async () => { + const timing = await measure(async () => { + await page.locator(`.session-tab[data-id="${sessionId}"] .tab-gear`).click(); + await page.waitForSelector('#sessionOptionsModal.active', { timeout: 2000 }); + }); + + console.log(`[session options] open: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.SESSION_OPTIONS_OPEN); + }); + + it('tab switching within modal is instant', async () => { + // Ensure modal is open + const isOpen = await page.locator('#sessionOptionsModal.active').isVisible().catch(() => false); + if (!isOpen) { + await page.locator(`.session-tab[data-id="${sessionId}"] .tab-gear`).click(); + await page.waitForSelector('#sessionOptionsModal.active', { timeout: 2000 }); + } + + const tabs = ['respawn', 'context', 'ralph']; + const timings: number[] = []; + + for (const tab of tabs) { + const timing = await measure(async () => { + await page.locator(`[data-tab="${tab}"]`).click(); + await page.waitForFunction( + (t: string) => document.querySelector(`[data-tab="${t}"]`)?.classList.contains('active'), + tab, + { timeout: 1000 }, + ); + }); + timings.push(timing); + } + + const avg = timings.reduce((a, b) => a + b, 0) / timings.length; + console.log(`[session options] tab switch avg: ${avg.toFixed(0)}ms (${tabs.join(', ')})`); + expect(avg).toBeLessThan(THRESHOLDS.SESSION_OPTIONS_TAB); + + // Close modal + await page.keyboard.press('Escape'); + }); +}); + +describe('Subagent window simulation', () => { + let context: BrowserContext; + let page: Page; + let sessionId: string; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + sessionId = await createSession(page, 'perf-subagent'); + await page.waitForSelector(`.session-tab[data-id="${sessionId}"]`, { timeout: 3000 }); + await page.locator(`.session-tab[data-id="${sessionId}"]`).click(); + await page.waitForTimeout(300); + }); + + afterAll(async () => { + await deleteSession(page, sessionId).catch(() => {}); + await context?.close(); + }); + + it('opens a mock subagent window within threshold', async () => { + // Must inject fake agent data into the subagents map first — + // openSubagentWindow checks this.subagents.get(agentId) and returns early if missing + const claudeSessionId = await page.evaluate((sid: string) => { + const app = (window as unknown as { app: { sessions: Map } }).app; + return app.sessions.get(sid)?.claudeSessionId || 'fake-claude-session'; + }, sessionId); + + const timing = await measure(async () => { + await page.evaluate(({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => { + const app = (window as unknown as { + app: { + subagents: Map>; + openSubagentWindow: (id: string) => void; + } + }).app; + // Inject fake agent data + app.subagents.set(agentId, { + agentId, + sessionId: cSessionId, + status: 'active', + description: 'Performance test agent', + startedAt: Date.now(), + lastActivityAt: Date.now(), + toolCallCount: 0, + entryCount: 0, + fileSize: 0, + }); + app.openSubagentWindow(agentId); + }, { agentId: 'perf-agent-1', cSessionId: claudeSessionId }); + await page.waitForSelector('.subagent-window', { timeout: 3000 }); + }); + + console.log(`[subagent window] open: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.SUBAGENT_WINDOW_OPEN); + }); + + it('closes subagent window within threshold', async () => { + // Ensure a window is open + const hasWindow = await page.locator('.subagent-window').count().then(c => c > 0); + if (!hasWindow) { + const claudeSessionId = await page.evaluate((sid: string) => { + const app = (window as unknown as { app: { sessions: Map } }).app; + return app.sessions.get(sid)?.claudeSessionId || 'fake-claude-session'; + }, sessionId); + await page.evaluate(({ cSessionId }: { cSessionId: string }) => { + const app = (window as unknown as { + app: { + subagents: Map>; + openSubagentWindow: (id: string) => void; + } + }).app; + app.subagents.set('perf-agent-close', { + agentId: 'perf-agent-close', + sessionId: cSessionId, + status: 'active', + description: 'Close test agent', + startedAt: Date.now(), + lastActivityAt: Date.now(), + toolCallCount: 0, + entryCount: 0, + fileSize: 0, + }); + app.openSubagentWindow('perf-agent-close'); + }, { cSessionId: claudeSessionId }); + await page.waitForSelector('.subagent-window', { timeout: 3000 }); + } + + const timing = await measure(async () => { + await page.evaluate(() => { + const app = (window as unknown as { app: { closeSubagentWindow: (id: string) => void } }).app; + const win = document.querySelector('.subagent-window'); + const id = win?.id?.replace('subagent-window-', '') || 'perf-agent-close'; + app.closeSubagentWindow(id); + }); + await page.waitForFunction( + () => document.querySelectorAll('.subagent-window').length === 0, + { timeout: 2000 }, + ); + }); + + console.log(`[subagent window] close: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.SUBAGENT_WINDOW_CLOSE); + }); + + it('opens 5 subagent windows without jank', async () => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + const claudeSessionId = await page.evaluate((sid: string) => { + const app = (window as unknown as { app: { sessions: Map } }).app; + return app.sessions.get(sid)?.claudeSessionId || 'fake-claude-session'; + }, sessionId); + + const timing = await measure(async () => { + for (let i = 0; i < 5; i++) { + await page.evaluate(({ idx, cSessionId }: { idx: number; cSessionId: string }) => { + const agentId = `perf-multi-agent-${idx}`; + const app = (window as unknown as { + app: { + subagents: Map>; + openSubagentWindow: (id: string) => void; + } + }).app; + app.subagents.set(agentId, { + agentId, + sessionId: cSessionId, + status: 'active', + description: `Perf agent ${idx}`, + startedAt: Date.now(), + lastActivityAt: Date.now(), + toolCallCount: 0, + entryCount: 0, + fileSize: 0, + }); + app.openSubagentWindow(agentId); + }, { idx: i, cSessionId: claudeSessionId }); + } + // Wait for all 5 + await page.waitForFunction( + () => document.querySelectorAll('.subagent-window').length >= 5, + { timeout: 5000 }, + ); + }); + + const windowCount = await page.locator('.subagent-window').count(); + console.log(`[subagent window] open 5: ${timing.toFixed(0)}ms (${windowCount} windows)`); + expect(timing).toBeLessThan(THRESHOLDS.SUBAGENT_WINDOW_OPEN * 5); + expect(errors).toHaveLength(0); + + // Clean up + await page.evaluate(() => { + const app = (window as unknown as { app: { closeSubagentWindow: (id: string) => void } }).app; + for (let i = 0; i < 5; i++) { + app.closeSubagentWindow(`perf-multi-agent-${i}`); + } + }); + }); +}); + +describe('SSE event throughput', () => { + let context: BrowserContext; + let page: Page; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + }); + + afterAll(async () => { + await context?.close(); + }); + + it('handles rapid session creation SSE events without errors', async () => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + const sessionIds: string[] = []; + const start = performance.now(); + + // Create 5 sessions rapidly + for (let i = 0; i < 5; i++) { + const id = await createSession(page, `perf-sse-${i}`); + sessionIds.push(id); + } + + // Wait for all tabs to appear + await page.waitForFunction( + (count: number) => document.querySelectorAll('.session-tab').length >= count, + sessionIds.length, + { timeout: 5000 }, + ); + const elapsed = performance.now() - start; + + console.log(`[SSE throughput] ${sessionIds.length} session events processed: ${elapsed.toFixed(0)}ms`); + expect(errors).toHaveLength(0); + + // Cleanup + for (const id of sessionIds) { + await deleteSession(page, id).catch(() => {}); + } + }); + + it('handles rapid session deletion SSE events', async () => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + // Create then delete rapidly + const sessionIds: string[] = []; + for (let i = 0; i < 5; i++) { + sessionIds.push(await createSession(page, `perf-del-${i}`)); + } + await page.waitForFunction( + (count: number) => document.querySelectorAll('.session-tab').length >= count, + sessionIds.length, + { timeout: 5000 }, + ); + + const start = performance.now(); + for (const id of sessionIds) { + await deleteSession(page, id); + } + // Wait for tabs to disappear + await page.waitForFunction( + () => { + const tabs = document.querySelectorAll('.session-tab'); + for (const tab of tabs) { + if (tab.getAttribute('data-id')?.startsWith('perf-del-')) return false; + } + return true; + }, + { timeout: 5000 }, + ); + const elapsed = performance.now() - start; + + console.log(`[SSE throughput] ${sessionIds.length} deletion events processed: ${elapsed.toFixed(0)}ms`); + expect(errors).toHaveLength(0); + }); +}); + +describe('Buffer loading performance', () => { + let context: BrowserContext; + let page: Page; + let sessionId: string; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + sessionId = await createSession(page, 'perf-buffer'); + await page.waitForSelector(`.session-tab[data-id="${sessionId}"]`, { timeout: 3000 }); + await page.locator(`.session-tab[data-id="${sessionId}"]`).click(); + await page.waitForTimeout(500); + }); + + afterAll(async () => { + await deleteSession(page, sessionId).catch(() => {}); + await context?.close(); + }); + + it('fetches terminal buffer via API quickly', async () => { + const timing = await measure(async () => { + await page.evaluate(async (sid: string) => { + const res = await fetch(`/api/sessions/${sid}/buffer`); + await res.text(); + }, sessionId); + }); + + console.log(`[buffer load] API fetch: ${timing.toFixed(0)}ms`); + expect(timing).toBeLessThan(THRESHOLDS.BUFFER_LOAD_16KB); + }); +}); + +describe('Memory usage under load', () => { + let context: BrowserContext; + let page: Page; + const sessionIds: string[] = []; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + }); + + afterAll(async () => { + for (const id of sessionIds) { + await deleteSession(page, id).catch(() => {}); + } + await context?.close(); + }); + + it('heap stays within limits after creating 10 sessions and switching', async () => { + const heapBefore = await getHeapMB(page); + + // Create 10 sessions + for (let i = 0; i < 10; i++) { + const id = await createSession(page, `perf-mem-${i}`); + sessionIds.push(id); + } + await page.waitForFunction( + (count: number) => document.querySelectorAll('.session-tab').length >= count, + 10, + { timeout: 5000 }, + ); + + // Switch through all tabs + for (const id of sessionIds) { + await page.locator(`.session-tab[data-id="${id}"]`).click(); + await page.waitForTimeout(50); + } + + const heapAfter = await getHeapMB(page); + const heapGrowth = heapAfter - heapBefore; + + console.log(`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`); + + // Heap should stay under absolute limit + if (heapAfter > 0) { // memory API may not be available + expect(heapAfter).toBeLessThan(THRESHOLDS.MEMORY_HEAP_MB); + } + }); +}); + +describe('No console errors during normal usage', () => { + let context: BrowserContext; + let page: Page; + + beforeAll(async () => { + ({ context, page } = await freshPage()); + }); + + afterAll(async () => { + await context?.close(); + }); + + it('page loads and runs without JS errors', async () => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await navigateAndWait(page); + + // Create a session, switch to it, open settings, close settings + const id = await createSession(page, 'perf-no-errors'); + await page.waitForSelector(`.session-tab[data-id="${id}"]`, { timeout: 3000 }); + await page.locator(`.session-tab[data-id="${id}"]`).click(); + await page.waitForTimeout(300); + + await page.locator('.btn-settings').click(); + await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 }); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + + await deleteSession(page, id).catch(() => {}); + await page.waitForTimeout(500); + + if (errors.length > 0) { + console.log('[errors]', errors); + } + expect(errors).toHaveLength(0); + }); +}); + +describe('Performance summary', () => { + it('prints threshold reference', () => { + console.log('\n--- Performance Thresholds ---'); + for (const [name, value] of Object.entries(THRESHOLDS)) { + console.log(` ${name}: ${value}ms`); + } + console.log('-----------------------------\n'); + }); +});