mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
chore: bump version to 0.1581
This commit is contained in:
@@ -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`)
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+9
-3
@@ -2001,16 +2001,22 @@ export class Session extends EventEmitter {
|
|||||||
return false;
|
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.
|
* Resizes the PTY terminal dimensions.
|
||||||
*
|
* Skips the resize if dimensions haven't changed to avoid triggering
|
||||||
* Call this when the frontend terminal is resized to keep PTY in sync.
|
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
|
||||||
*
|
*
|
||||||
* @param cols - Number of columns (width in characters)
|
* @param cols - Number of columns (width in characters)
|
||||||
* @param rows - Number of rows (height in lines)
|
* @param rows - Number of rows (height in lines)
|
||||||
*/
|
*/
|
||||||
resize(cols: number, rows: number): void {
|
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);
|
this.ptyProcess.resize(cols, rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-21
@@ -970,7 +970,7 @@ class LocalEchoOverlay {
|
|||||||
// Create a styled line div with opaque background
|
// Create a styled line div with opaque background
|
||||||
_makeLine(text, leftPx, topPx, widthPx, cellH) {
|
_makeLine(text, leftPx, topPx, widthPx, cellH) {
|
||||||
const el = document.createElement('div');
|
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.fontFamily = this._fontFamily;
|
||||||
el.style.fontSize = this._fontSize;
|
el.style.fontSize = this._fontSize;
|
||||||
el.style.fontWeight = this._fontWeight;
|
el.style.fontWeight = this._fontWeight;
|
||||||
@@ -980,7 +980,7 @@ class LocalEchoOverlay {
|
|||||||
el.style.left = leftPx + 'px';
|
el.style.left = leftPx + 'px';
|
||||||
el.style.top = topPx + 'px';
|
el.style.top = topPx + 'px';
|
||||||
el.style.width = widthPx + 'px';
|
el.style.width = widthPx + 'px';
|
||||||
el.style.height = cellH + 'px';
|
el.style.height = (cellH + 1) + 'px';
|
||||||
el.style.lineHeight = cellH + 'px';
|
el.style.lineHeight = cellH + 'px';
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
return el;
|
return el;
|
||||||
@@ -1030,7 +1030,6 @@ class LocalEchoOverlay {
|
|||||||
// Position overlay container at prompt row
|
// Position overlay container at prompt row
|
||||||
this.overlay.style.left = '0px';
|
this.overlay.style.left = '0px';
|
||||||
this.overlay.style.top = (activePrompt.row * cellH) + 'px';
|
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)
|
// Build line elements — recreate on each render (typically 1-3 divs, negligible cost)
|
||||||
this.overlay.innerHTML = '';
|
this.overlay.innerHTML = '';
|
||||||
@@ -1040,8 +1039,6 @@ class LocalEchoOverlay {
|
|||||||
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
|
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
|
||||||
const topPx = i * cellH;
|
const topPx = i * cellH;
|
||||||
const lineEl = this._makeLine(lines[i], leftPx, topPx, widthPx, 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);
|
this.overlay.appendChild(lineEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2852,8 +2849,8 @@ class ClaudemanApp {
|
|||||||
await this.chunkedTerminalWrite(cleanBuffer);
|
await this.chunkedTerminalWrite(cleanBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send resize to ensure proper dimensions
|
// Fire-and-forget resize — don't block on it
|
||||||
await this.sendResize(data.id);
|
this.sendResize(data.id);
|
||||||
// Re-position local echo overlay at new prompt location
|
// Re-position local echo overlay at new prompt location
|
||||||
this._localEchoOverlay?.rerender();
|
this._localEchoOverlay?.rerender();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2911,7 +2908,6 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
addListener('session:idle', (e) => {
|
addListener('session:idle', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
console.log('[DEBUG] session:idle event for:', data.id);
|
|
||||||
const session = this.sessions.get(data.id);
|
const session = this.sessions.get(data.id);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.status = 'idle';
|
session.status = 'idle';
|
||||||
@@ -2940,7 +2936,6 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
addListener('session:working', (e) => {
|
addListener('session:working', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
console.log('[DEBUG] session:working event for:', data.id);
|
|
||||||
const session = this.sessions.get(data.id);
|
const session = this.sessions.get(data.id);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.status = 'busy';
|
session.status = 'busy';
|
||||||
@@ -4719,18 +4714,24 @@ class ClaudemanApp {
|
|||||||
if (selectGen !== this._selectGeneration) return; // stale — newer selectSession won
|
if (selectGen !== this._selectGeneration) return; // stale — newer selectSession won
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
this.terminal.clear();
|
|
||||||
this.terminal.reset();
|
|
||||||
if (data.terminalBuffer) {
|
if (data.terminalBuffer) {
|
||||||
// Show truncation indicator if buffer was cut
|
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||||
if (data.truncated) {
|
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||||
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
|
// 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)
|
// Update cache (cap at 20 entries)
|
||||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||||
@@ -4739,6 +4740,10 @@ class ClaudemanApp {
|
|||||||
const oldest = this.terminalBufferCache.keys().next().value;
|
const oldest = this.terminalBufferCache.keys().next().value;
|
||||||
this.terminalBufferCache.delete(oldest);
|
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).
|
// 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
|
// Fire-and-forget resize — don't await to avoid blocking UI.
|
||||||
await this.sendResize(sessionId);
|
// 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
|
// Defer secondary panel updates so they don't block the main thread
|
||||||
// after terminal content is already visible.
|
// after terminal content is already visible.
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void>): Promise<number> {
|
||||||
|
const start = performance.now();
|
||||||
|
await fn();
|
||||||
|
return performance.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get JS heap size in MB (Chromium only) */
|
||||||
|
async function getHeapMB(page: Page): Promise<number> {
|
||||||
|
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<string, { claudeSessionId?: string }> } }).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<string, Record<string, unknown>>;
|
||||||
|
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<string, { claudeSessionId?: string }> } }).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<string, Record<string, unknown>>;
|
||||||
|
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<string, { claudeSessionId?: string }> } }).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<string, Record<string, unknown>>;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user