mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Move the nightly cron from 03:17 to 03:23 UTC. GitHub sends scheduled-run
failure notices to whoever last modified the cron line, and after the merge
that is the contributor, so a maintainer commit has to touch it. The
docs below give no clock time, so they cannot drift from the cron.
Drop the "Keep the failure artifacts" step and the blank line before it.
No browser test writes test-results/ or screenshots-echo-diag/ (only the
ignore files name them), and if-no-files-found: ignore made the step upload
nothing without a word. The run log already carries the failure output.
Reword the workflow header. Drop the claim that the skipped suite let two
semantically conflicting PRs merge green: that incident came from
test/mobile/keyboard.test.ts, which this job does not run. Correct the
codex-predictive-echo note: the test uses a fake key in a throwaway
CODEX_HOME and skips itself when codex is missing, so it needs a codex
binary, not an authenticated one.
opencode-resize: record WebSocket resize frames under the socket's own URL
instead of appending '#' + the session id. The URL already carries
/ws/sessions/<id>/terminal, and the suffix let toContain(sessionId) pass for
a resize sent on any session's socket, the bug this test exists to catch.
Reduce the six session-id extractions (opencode-resize and perf-browser) to
data.data?.session?.id. POST /api/sessions always answers in the
{ success, data: { session } } envelope, and the dead fallbacks are what
hid the original breakage.
split-pane-terminal: restore the browser config's 60 s test timeout (the
added 20000 ms override tightened it), and replace the comment that blamed
Codeman's post-create clear. Under vitest the session is an echo PTY, so
that clear comes back as text; the real fix is useMux:false, since a plain
prompt otherwise goes through tmux send-keys, which test mode no-ops.
CLAUDE.md: the CI note now says the gate excludes the Playwright tests in
BROWSER_TEST_GLOBS instead of a stale count of 14, and names
browser-suite.yml; the Testing warning says the browser suite runs nightly.
CONTRIBUTING.md gets the same one-line pointer under Tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
988 lines
33 KiB
TypeScript
988 lines
33 KiB
TypeScript
/**
|
|
* 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, // Browser nav timing through deferred script execution
|
|
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();
|
|
// POST /api/sessions answers in the { success, data: { session } } envelope.
|
|
return data.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;
|
|
}
|
|
|
|
type BrowserNavigationTiming = {
|
|
domInteractive: number;
|
|
domContentLoadedEventEnd: number;
|
|
loadEventEnd: number;
|
|
};
|
|
|
|
/** Get the browser's own navigation timing, excluding Playwright harness overhead. */
|
|
async function getBrowserNavigationTiming(page: Page): Promise<BrowserNavigationTiming> {
|
|
return page.evaluate(() => {
|
|
const entry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
|
if (!entry) throw new Error('Navigation timing entry not available');
|
|
return {
|
|
domInteractive: entry.domInteractive,
|
|
domContentLoadedEventEnd: entry.domContentLoadedEventEnd,
|
|
loadEventEnd: entry.loadEventEnd,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 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 wallTiming = await measure(async () => {
|
|
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
|
});
|
|
const navigationTiming = await getBrowserNavigationTiming(page);
|
|
|
|
console.log(
|
|
`[page load] DOMContentLoaded: ${navigationTiming.domContentLoadedEventEnd.toFixed(0)}ms (wall ${wallTiming.toFixed(0)}ms)`
|
|
);
|
|
expect(navigationTiming.domContentLoadedEventEnd).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 (minimizes) subagent window within threshold', async () => {
|
|
// perf-agent-1 should still be open from the previous test
|
|
// Note: closeSubagentWindow minimizes to tab (display:none), doesn't remove from DOM
|
|
const isVisible = await page.locator('#subagent-window-perf-agent-1').isVisible();
|
|
expect(isVisible).toBe(true);
|
|
|
|
const timing = await measure(async () => {
|
|
await page.evaluate(() => {
|
|
const app = (window as unknown as { app: { closeSubagentWindow: (id: string) => void } }).app;
|
|
app.closeSubagentWindow('perf-agent-1');
|
|
});
|
|
// Wait for element to become hidden (display: none)
|
|
await page.waitForFunction(
|
|
() => {
|
|
const el = document.getElementById('subagent-window-perf-agent-1');
|
|
return el && el.style.display === 'none';
|
|
},
|
|
{ timeout: 2000 }
|
|
);
|
|
});
|
|
|
|
console.log(`[subagent window] close (minimize): ${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');
|
|
});
|
|
});
|