Files
pikasTech-unidesk/scripts/src/code-queue-perf.ts
T

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;