mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +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`)
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+9
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-21
@@ -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.
|
||||
|
||||
@@ -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