222 lines
8.0 KiB
TypeScript
222 lines
8.0 KiB
TypeScript
import { chromium, type BrowserContext, type Request } from "playwright";
|
|
import { existsSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { readConfig } from "./config";
|
|
|
|
interface CodeQueuePerfOptions {
|
|
url: string;
|
|
username: string;
|
|
password: string;
|
|
timeoutMs: number;
|
|
targetMs: number;
|
|
headless: boolean;
|
|
json: boolean;
|
|
}
|
|
|
|
interface ApiTiming {
|
|
url: string;
|
|
method: string;
|
|
status: number;
|
|
durationMs: number;
|
|
}
|
|
|
|
function argValue(args: string[], name: string): string | null {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return null;
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function numberArg(args: string[], name: string, fallback: number): number {
|
|
const raw = argValue(args, name);
|
|
if (raw === null) return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
|
return Math.floor(value);
|
|
}
|
|
|
|
function normalizeBaseUrl(value: string): string {
|
|
return value.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function readOptions(): CodeQueuePerfOptions {
|
|
const config = readConfig();
|
|
const args = process.argv.slice(2);
|
|
return {
|
|
url: normalizeBaseUrl(argValue(args, "--url") ?? `http://${config.network.publicHost}:${config.network.frontend.port}`),
|
|
username: argValue(args, "--username") ?? config.auth.username,
|
|
password: argValue(args, "--password") ?? config.auth.password,
|
|
timeoutMs: numberArg(args, "--timeout-ms", 90_000),
|
|
targetMs: numberArg(args, "--target-ms", 1_000),
|
|
headless: !args.includes("--headed"),
|
|
json: args.includes("--json"),
|
|
};
|
|
}
|
|
|
|
async function authenticateSession(context: BrowserContext, options: CodeQueuePerfOptions): Promise<void> {
|
|
const response = await fetch(`${options.url}/login`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ username: options.username, password: options.password }),
|
|
signal: AbortSignal.timeout(options.timeoutMs),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`login failed: HTTP ${response.status} ${await response.text()}`);
|
|
}
|
|
const setCookie = response.headers.get("set-cookie");
|
|
const cookiePair = setCookie?.split(";", 1)[0] ?? "";
|
|
const separator = cookiePair.indexOf("=");
|
|
if (separator <= 0) throw new Error("login response did not set a session cookie");
|
|
await context.addCookies([{
|
|
name: cookiePair.slice(0, separator),
|
|
value: cookiePair.slice(separator + 1),
|
|
url: options.url,
|
|
}]);
|
|
}
|
|
|
|
function requestPath(request: Request): string {
|
|
try {
|
|
const url = new URL(request.url());
|
|
return `${url.pathname}${url.search}`;
|
|
} catch {
|
|
return request.url();
|
|
}
|
|
}
|
|
|
|
function parseNumberAttr(value: string | null): number | null {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
async function runCodeQueuePerf(options: CodeQueuePerfOptions): Promise<Record<string, unknown>> {
|
|
const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH || ".state/playwright-browsers";
|
|
const fullChromePath = resolve(browsersPath, "chromium-1217/chrome-linux64/chrome");
|
|
const launchArgs = [
|
|
"--no-sandbox",
|
|
"--disable-setuid-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
];
|
|
const launchOptions: Parameters<typeof chromium.launch>[0] = { headless: options.headless, args: launchArgs };
|
|
if (existsSync(fullChromePath)) {
|
|
launchOptions.executablePath = fullChromePath;
|
|
}
|
|
const browser = await chromium.launch(launchOptions);
|
|
const apiStartedAt = new Map<Request, number>();
|
|
const apiTimings: ApiTiming[] = [];
|
|
const consoleErrors: string[] = [];
|
|
const targetUrl = `${options.url}/app/code-queue/`;
|
|
const measuredAt = new Date().toISOString();
|
|
|
|
try {
|
|
const context = await browser.newContext();
|
|
await authenticateSession(context, options);
|
|
const page = await context.newPage();
|
|
await page.route("**/favicon.ico", (route) => route.abort());
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") consoleErrors.push(message.text().slice(0, 500));
|
|
});
|
|
page.on("request", (request) => {
|
|
const path = requestPath(request);
|
|
if (path.startsWith("/api/") || path === "/app.js") apiStartedAt.set(request, performance.now());
|
|
});
|
|
page.on("response", (response) => {
|
|
const request = response.request();
|
|
const started = apiStartedAt.get(request);
|
|
if (started === undefined) return;
|
|
apiStartedAt.delete(request);
|
|
apiTimings.push({
|
|
url: requestPath(request),
|
|
method: request.method(),
|
|
status: response.status(),
|
|
durationMs: Math.round((performance.now() - started) * 10) / 10,
|
|
});
|
|
});
|
|
|
|
const startedAt = performance.now();
|
|
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: options.timeoutMs });
|
|
const domContentLoadedMs = Math.round((performance.now() - startedAt) * 10) / 10;
|
|
await page.waitForSelector('[data-testid="code-queue-page"][data-load-state="complete"]', { timeout: options.timeoutMs });
|
|
const completeAt = performance.now();
|
|
const dom = await page.evaluate(() => {
|
|
const pageElement = document.querySelector('[data-testid="code-queue-page"]') as HTMLElement | null;
|
|
const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null;
|
|
const tasks = document.querySelectorAll('[data-testid^="codex-task-codex_"]');
|
|
return {
|
|
loadState: pageElement?.dataset.loadState ?? "",
|
|
componentLoadMs: pageElement?.dataset.loadTotalMs ?? "",
|
|
queueMs: pageElement?.dataset.loadQueueMs ?? "",
|
|
detailMs: pageElement?.dataset.loadDetailMs ?? "",
|
|
transcriptRows: pageElement?.dataset.loadTranscriptRows ?? "",
|
|
taskId: pageElement?.dataset.loadTaskId ?? "",
|
|
partial: pageElement?.dataset.loadPartial === "true",
|
|
visibleTaskCount: tasks.length,
|
|
outputChars: output?.textContent?.length ?? 0,
|
|
};
|
|
});
|
|
let networkIdleReached = true;
|
|
try {
|
|
await page.waitForLoadState("networkidle", { timeout: 5_000 });
|
|
} catch {
|
|
networkIdleReached = false;
|
|
}
|
|
const networkIdleMs = Math.round((performance.now() - startedAt) * 10) / 10;
|
|
const playwrightObservedMs = Math.round((completeAt - startedAt) * 10) / 10;
|
|
const browserLoadMs = parseNumberAttr(dom.componentLoadMs);
|
|
const totalLoadMs = browserLoadMs ?? playwrightObservedMs;
|
|
|
|
const slowestApi = apiTimings.slice().sort((left, right) => right.durationMs - left.durationMs).slice(0, 8);
|
|
const result = {
|
|
ok: true,
|
|
measuredAt,
|
|
url: targetUrl,
|
|
targetMs: options.targetMs,
|
|
withinTarget: totalLoadMs <= options.targetMs,
|
|
status: totalLoadMs <= options.targetMs ? "passed" : "slow",
|
|
wallMs: totalLoadMs,
|
|
totalLoadMs,
|
|
domContentLoadedMs,
|
|
appCompleteMs: playwrightObservedMs,
|
|
playwrightObservedMs,
|
|
networkIdleMs,
|
|
networkIdleReached,
|
|
componentLoadMs: browserLoadMs,
|
|
queueMs: parseNumberAttr(dom.queueMs),
|
|
detailMs: parseNumberAttr(dom.detailMs),
|
|
transcriptRows: parseNumberAttr(dom.transcriptRows),
|
|
partial: dom.partial,
|
|
selectedTaskId: dom.taskId || null,
|
|
visibleTaskCount: dom.visibleTaskCount,
|
|
outputChars: dom.outputChars,
|
|
apiRequestCount: apiTimings.length,
|
|
slowestApi,
|
|
consoleErrors,
|
|
};
|
|
await context.close();
|
|
return result;
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
measuredAt,
|
|
url: targetUrl,
|
|
targetMs: options.targetMs,
|
|
status: "failed",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
apiRequestCount: apiTimings.length,
|
|
slowestApi: apiTimings.slice().sort((left, right) => right.durationMs - left.durationMs).slice(0, 8),
|
|
consoleErrors,
|
|
};
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
const options = readOptions();
|
|
const result = await runCodeQueuePerf(options);
|
|
if (options.json) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
}
|
|
if (result.ok !== true) process.exitCode = 1;
|