Path: blob/main/scripts/chat-simulation/common/utils.js
13383 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45// @ts-check67/**8* Shared utilities for chat performance benchmarks and leak checks.9*10* Platform: macOS and Linux only. Windows is not supported — several11* utilities (`sqlite3`, `sleep`, `pkill`) are Unix-specific.12* CI runs on ubuntu-latest.13*/1415const path = require('path');16const fs = require('fs');17const os = require('os');18const http = require('http');19const { execSync, execFileSync, spawn } = require('child_process');2021const ROOT = path.join(__dirname, '..', '..', '..');22const DATA_DIR = path.join(ROOT, '.chat-simulation-data');2324// -- Config loading ----------------------------------------------------------2526/** @param {string} text */27function stripJsoncComments(text) { return text.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); }2829/**30* Load a namespaced section from config.jsonc.31* @param {string} section - Top-level key (e.g. 'perfRegression', 'memLeaks')32* @returns {Record<string, any>}33*/34function loadConfig(section) {35const raw = fs.readFileSync(path.join(__dirname, '..', 'config.jsonc'), 'utf-8');36const config = JSON.parse(stripJsoncComments(raw));37return config[section] ?? {};38}3940// -- Electron path resolution ------------------------------------------------4142/**43* Derive the VS Code repo root from an Electron executable path.44* Dev builds live at `<repo>/.build/electron/<app>/`, so we walk up45* from the path to find the directory containing `.build`.46* Returns `undefined` if the path doesn't look like a dev build.47* @param {string} electronPath48* @returns {string | undefined}49*/50function getRepoRoot(electronPath) {51const buildIdx = electronPath.indexOf(`${path.sep}.build${path.sep}`);52if (buildIdx === -1) {53// Also check for posix separators (path may be user-supplied)54const posixIdx = electronPath.indexOf('/.build/');55if (posixIdx === -1) { return undefined; }56return electronPath.slice(0, posixIdx);57}58return electronPath.slice(0, buildIdx);59}6061function getElectronPath() {62const product = require(path.join(ROOT, 'product.json'));63if (process.platform === 'darwin') {64return path.join(ROOT, '.build', 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', product.nameShort);65} else if (process.platform === 'linux') {66return path.join(ROOT, '.build', 'electron', product.applicationName);67} else {68return path.join(ROOT, '.build', 'electron', `${product.nameShort}.exe`);69}70}7172/**73* Returns true if the string looks like a VS Code version or commit hash74* rather than a file path.75* @param {string} value76*/77function isVersionString(value) {78if (value === 'insiders' || value === 'stable') { return true; }79if (/^\d+\.\d+\.\d+/.test(value)) { return true; }80if (/^[0-9a-f]{7,40}$/.test(value)) { return true; }81return false;82}8384/**85* Get the built-in extensions directory for a VS Code executable.86* @param {string} exePath87* @returns {string | undefined}88*/89function getBuiltinExtensionsDir(exePath) {90if (process.platform === 'darwin') {91const appDir = exePath.split('/Contents/')[0];92return path.join(appDir, 'Contents', 'Resources', 'app', 'extensions');93} else if (process.platform === 'linux') {94return path.join(path.dirname(exePath), 'resources', 'app', 'extensions');95} else {96return path.join(path.dirname(exePath), 'resources', 'app', 'extensions');97}98}99100/**101* Resolve a build arg to an executable path.102* Version strings are downloaded via @vscode/test-electron.103* @param {string | undefined} buildArg104* @returns {Promise<string>}105*/106async function resolveBuild(buildArg) {107if (!buildArg) {108return getElectronPath();109}110if (isVersionString(buildArg)) {111console.log(`[chat-simulation] Downloading VS Code ${buildArg}...`);112const { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } = require('@vscode/test-electron');113const exePath = await downloadAndUnzipVSCode(buildArg);114console.log(`[chat-simulation] Downloaded: ${exePath}`);115116// Check if copilot is already bundled as a built-in extension117// (recent Insiders/Stable builds ship it in the app's extensions/ dir).118const builtinExtDir = getBuiltinExtensionsDir(exePath);119const hasCopilotBuiltin = builtinExtDir && fs.existsSync(builtinExtDir)120&& fs.readdirSync(builtinExtDir).some(e => e === 'copilot');121122if (hasCopilotBuiltin) {123console.log(`[chat-simulation] Copilot is bundled as a built-in extension`);124} else {125// Install copilot-chat from the marketplace into our shared126// extensions dir so it's available when we launch with127// --extensions-dir=DATA_DIR/extensions.128const extDir = path.join(DATA_DIR, 'extensions');129fs.mkdirSync(extDir, { recursive: true });130const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(exePath);131const extId = 'GitHub.copilot-chat';132console.log(`[chat-simulation] Installing ${extId} into ${extDir}...`);133const { spawnSync } = require('child_process');134const result = spawnSync(cli, [...cliArgs, '--extensions-dir', extDir, '--install-extension', extId], {135encoding: 'utf-8',136stdio: 'pipe',137shell: process.platform === 'win32',138timeout: 120_000,139});140if (result.status !== 0) {141console.warn(`[chat-simulation] Extension install exited with ${result.status}: ${(result.stderr || '').substring(0, 500)}`);142} else {143console.log(`[chat-simulation] ${extId} installed`);144}145}146147return exePath;148}149return path.resolve(buildArg);150}151152// -- Storage pre-seeding -----------------------------------------------------153154/**155* Pre-seed the VS Code storage database to prevent the156* BuiltinChatExtensionEnablementMigration from disabling the copilot157* extension on fresh user data directories.158*159* Requires `sqlite3` on PATH (pre-installed on macOS and Ubuntu).160* @param {string} userDataDir161*/162function preseedStorage(userDataDir) {163const globalStorageDir = path.join(userDataDir, 'User', 'globalStorage');164fs.mkdirSync(globalStorageDir, { recursive: true });165const dbPath = path.join(globalStorageDir, 'state.vscdb');166const sql = [167'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);',168'INSERT INTO ItemTable (key, value) VALUES (\'builtinChatExtensionEnablementMigration\', \'true\');',169'INSERT INTO ItemTable (key, value) VALUES (\'chat.tools.global.autoApprove.optIn\', \'true\');',170].join(' ');171execFileSync('sqlite3', [dbPath, sql]);172}173174// -- Launch helpers ----------------------------------------------------------175176/**177* Build the environment variables for launching VS Code with the mock server.178* @param {{ url: string }} mockServer179* @param {{ isDevBuild?: boolean }} [opts]180* @returns {Record<string, string>}181*/182function buildEnv(mockServer, { isDevBuild = true } = {}) {183/** @type {Record<string, string>} */184const env = {185...process.env,186ELECTRON_ENABLE_LOGGING: '1',187IS_SCENARIO_AUTOMATION: '1',188GITHUB_PAT: 'perf-benchmark-fake-pat',189VSCODE_COPILOT_CHAT_TOKEN: Buffer.from(JSON.stringify({190token: 'perf-benchmark-fake-token',191expires_at: Math.floor(Date.now() / 1000) + 3600,192refresh_in: 1800,193sku: 'free_limited_copilot',194individual: true,195isNoAuthUser: true,196copilot_plan: 'free',197organization_login_list: [],198endpoints: { api: mockServer.url, proxy: mockServer.url },199})).toString('base64'),200};201// Dev-only flags — these tell Electron to load the app from source (out/)202// instead of the packaged app. Setting them on a stable build causes it203// to fail to show a window.204if (isDevBuild) {205env.NODE_ENV = 'development';206env.VSCODE_DEV = '1';207env.VSCODE_CLI = '1';208}209return env;210}211212/**213* Build the default VS Code launch args.214* @param {string} userDataDir215* @param {string} extDir216* @param {string} logsDir217* @returns {string[]}218*/219function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostInspectPort = 0, traceFile = '', appRoot = ROOT } = {}) {220// Chromium switches must come BEFORE the app path (ROOT) — Chromium221// only processes switches that precede the first non-switch argument.222const chromiumFlags = [];223if (traceFile) {224chromiumFlags.push(`--enable-tracing=v8.gc,disabled-by-default-v8.gc,disabled-by-default-v8.gc_stats,devtools.timeline,blink.user_timing`);225chromiumFlags.push(`--trace-startup-file=${traceFile}`);226chromiumFlags.push(`--enable-tracing-format=json`);227}228const args = [229...chromiumFlags,230appRoot,231'--skip-release-notes',232'--skip-welcome',233'--disable-telemetry',234'--disable-updates',235'--disable-workspace-trust',236`--user-data-dir=${userDataDir}`,237`--extensions-dir=${extDir}`,238`--logsPath=${logsDir}`,239'--enable-smoke-test-driver',240'--disable-extensions',241];242// vscode-api-tests only exists in the dev build243if (isDevBuild) {244args.push('--disable-extension=vscode.vscode-api-tests');245}246if (process.platform !== 'darwin') {247args.push('--disable-gpu');248}249if (process.env.CI && process.platform === 'linux') {250args.push('--no-sandbox');251}252// Enable extension host inspector for profiling/heap snapshots253if (extHostInspectPort > 0) {254args.push(`--inspect-extensions=${extHostInspectPort}`);255}256return args;257}258259/**260* Write VS Code settings that point the copilot extension at the mock server.261* @param {string} userDataDir262* @param {{ url: string }} mockServer263* @param {Record<string, any>} [overrides]264*/265function writeSettings(userDataDir, mockServer, overrides) {266const settingsDir = path.join(userDataDir, 'User');267fs.mkdirSync(settingsDir, { recursive: true });268fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({269'github.copilot.advanced.debug.overrideProxyUrl': mockServer.url,270'github.copilot.advanced.debug.overrideCapiUrl': mockServer.url,271'chat.allowAnonymousAccess': true,272// Disable MCP servers — they start async and add unpredictable273// delay that pollutes perf measurements.274'chat.mcp.discovery.enabled': false,275'chat.mcp.enabled': false,276'github.copilot.chat.githubMcpServer.enabled': false,277'github.copilot.chat.cli.mcp.enabled': false,278// Auto-approve all tool invocations (YOLO mode) so tool call279// scenarios don't block on confirmation dialogs.280'chat.tools.global.autoApprove': true,281...overrides,282}, null, '\t'));283}284285/**286* Prepare a fresh run directory (clean, create, preseed, write settings).287* @param {string} runId288* @param {{ url: string }} mockServer289* @param {Record<string, any>} [settingsOverrides]290* @returns {{ userDataDir: string, extDir: string, logsDir: string }}291*/292function prepareRunDir(runId, mockServer, settingsOverrides) {293const tmpBase = path.join(os.tmpdir(), 'vscode-chat-simulation');294const userDataDir = path.join(tmpBase, `run-${runId}`);295const extDir = path.join(DATA_DIR, 'extensions');296const logsDir = path.join(tmpBase, 'logs', `run-${runId}`);297// Retry rmSync to handle ENOTEMPTY race conditions from Electron cache locks298for (let attempt = 0; attempt < 3; attempt++) {299try {300fs.rmSync(userDataDir, { recursive: true, force: true });301break;302} catch (err) {303const error = /** @type {NodeJS.ErrnoException} */ (err);304if (attempt < 2 && error.code === 'ENOTEMPTY') {305require('child_process').execSync(`sleep 0.5`);306} else {307throw error;308}309}310}311fs.mkdirSync(userDataDir, { recursive: true });312fs.mkdirSync(extDir, { recursive: true });313fs.mkdirSync(logsDir, { recursive: true });314preseedStorage(userDataDir);315writeSettings(userDataDir, mockServer, settingsOverrides);316return { userDataDir, extDir, logsDir };317}318319// -- VS Code launch via CDP --------------------------------------------------320321// -- Extension host inspector ------------------------------------------------322323/** @type {number} */324let nextExtHostPort = 29222;325326/** @returns {number} */327function getNextExtHostInspectPort() {328return nextExtHostPort++;329}330331/**332* Connect to the extension host's Node inspector via WebSocket.333* The extension host must be started with `--inspect-extensions=<port>`.334*335* @param {number} port336* @param {{ verbose?: boolean, timeoutMs?: number }} [opts]337* @returns {Promise<{ send: (method: string, params?: any) => Promise<any>, on: (event: string, listener: (params: any) => void) => void, close: () => void, port: number }>}338*/339async function connectToExtHostInspector(port, opts = {}) {340const { verbose = false, timeoutMs = 30_000 } = opts;341342// Wait for the inspector endpoint to be available343const deadline = Date.now() + timeoutMs;344/** @type {any} */345let wsUrl;346while (Date.now() < deadline) {347try {348const targets = await getJson(`http://127.0.0.1:${port}/json`);349if (targets.length > 0 && targets[0].webSocketDebuggerUrl) {350wsUrl = targets[0].webSocketDebuggerUrl;351break;352}353} catch { }354await new Promise(r => setTimeout(r, 500));355}356if (!wsUrl) {357throw new Error(`Timed out waiting for extension host inspector on port ${port}`);358}359360if (verbose) {361console.log(` [ext-host] Connected to inspector: ${wsUrl}`);362}363364const WebSocket = require('ws');365const ws = new WebSocket(wsUrl);366await new Promise((resolve, reject) => {367ws.once('open', resolve);368ws.once('error', reject);369});370371let msgId = 1;372/** @type {Map<number, { resolve: (v: any) => void, reject: (e: Error) => void }>} */373const pending = new Map();374/** @type {Map<string, ((params: any) => void)[]>} */375const eventListeners = new Map();376377ws.on('message', (/** @type {Buffer} */ data) => {378const msg = JSON.parse(data.toString());379if (msg.id !== undefined) {380const p = pending.get(msg.id);381if (p) {382pending.delete(msg.id);383if (msg.error) { p.reject(new Error(msg.error.message)); }384else { p.resolve(msg.result); }385}386} else if (msg.method) {387const listeners = eventListeners.get(msg.method) || [];388for (const listener of listeners) { listener(msg.params); }389}390});391392return {393port,394/**395* @param {string} method396* @param {any} [params]397* @returns {Promise<any>}398*/399send(method, params) {400return new Promise((resolve, reject) => {401const id = msgId++;402pending.set(id, { resolve, reject });403ws.send(JSON.stringify({ id, method, params }));404setTimeout(() => {405if (pending.has(id)) {406pending.delete(id);407reject(new Error(`Inspector call timed out: ${method}`));408}409}, 30_000);410});411},412/**413* @param {string} event414* @param {(params: any) => void} listener415*/416on(event, listener) {417const list = eventListeners.get(event) || [];418list.push(listener);419eventListeners.set(event, list);420},421close() {422ws.close();423},424};425}426427/**428* Fetch JSON from a URL. Used to probe the CDP endpoint.429* @param {string} url430* @returns {Promise<any>}431*/432function getJson(url) {433return new Promise((resolve, reject) => {434http.get(url, res => {435let data = '';436res.on('data', chunk => { data += chunk; });437res.on('end', () => {438try { resolve(JSON.parse(data)); }439catch { reject(new Error(`Invalid JSON from ${url}`)); }440});441}).on('error', reject);442});443}444445/**446* Wait until VS Code exposes its CDP endpoint.447* @param {number} port448* @param {number} timeoutMs449* @returns {Promise<void>}450*/451async function waitForCDP(port, timeoutMs = 60_000) {452const deadline = Date.now() + timeoutMs;453while (Date.now() < deadline) {454try {455await getJson(`http://127.0.0.1:${port}/json/version`);456return;457} catch {458await new Promise(r => setTimeout(r, 500));459}460}461throw new Error(`Timed out waiting for CDP on port ${port}`);462}463464/**465* Find the workbench page among all CDP pages.466* For dev builds this checks for `globalThis.driver` (smoke-test driver).467* For stable builds it checks for `.monaco-workbench` in the DOM.468* @param {import('playwright').Browser} browser469* @param {number} timeoutMs470* @returns {Promise<import('playwright').Page>}471*/472async function findWorkbenchPage(browser, timeoutMs = 60_000) {473const deadline = Date.now() + timeoutMs;474while (Date.now() < deadline) {475const pages = browser.contexts().flatMap(ctx => ctx.pages());476for (const page of pages) {477const hasWorkbench = await page.evaluate(() =>478// @ts-ignore479!!globalThis.driver?.whenWorkbenchRestored || !!document.querySelector('.monaco-workbench')480).catch(() => false);481if (hasWorkbench) {482return page;483}484}485await new Promise(r => setTimeout(r, 500));486}487throw new Error('Timed out waiting for the workbench page');488}489490/** @type {number} */491let nextPort = 19222;492493/**494* Launch VS Code via child_process and connect via CDP.495* Works with dev builds, insiders, and stable releases.496*497* @param {string} executable - Path to the VS Code executable (Electron binary or CLI)498* @param {string[]} launchArgs - Arguments to pass to the executable499* @param {Record<string, string>} env - Environment variables500* @param {{ verbose?: boolean }} [opts]501* @returns {Promise<{ page: import('playwright').Page, browser: import('playwright').Browser, close: () => Promise<void> }>}502*/503async function launchVSCode(executable, launchArgs, env, opts = {}) {504const { chromium } = require('playwright');505const port = nextPort++;506507const args = [`--remote-debugging-port=${port}`, ...launchArgs];508const isShell = process.platform === 'win32';509510if (opts.verbose) {511console.log(` [launch] ${executable} ${args.slice(0, 3).join(' ')} ... (port ${port})`);512}513514const child = spawn(executable, args, {515cwd: ROOT,516env,517shell: isShell,518stdio: opts.verbose ? 'inherit' : ['ignore', 'ignore', 'ignore'],519});520521// Track early exit522let exitError = /** @type {Error | null} */ (null);523child.once('exit', (code, signal) => {524if (!exitError) {525exitError = new Error(`VS Code exited before CDP connected (code=${code} signal=${signal})`);526}527});528529// Wait for CDP530try {531await waitForCDP(port);532} catch (e) {533if (exitError) { throw exitError; }534throw e;535}536537const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);538const page = await findWorkbenchPage(browser);539540return {541page,542browser,543close: async () => {544// Trigger app.quit() so Chromium flushes trace buffers and545// writes --trace-startup-file. Using Cmd+Q / Alt+F4 triggers546// the full Electron quit lifecycle including trace flush.547// window.close() only closes the BrowserWindow without548// triggering app-level quit.549try {550const quitKey = process.platform === 'darwin' ? 'Meta+KeyQ' : 'Alt+F4';551await page.keyboard.press(quitKey);552} catch {553// Page may already be closed554}555const pid = child.pid;556// Wait for graceful exit (up to 30s for trace flush)557await new Promise(resolve => {558const timer = setTimeout(() => {559if (pid) {560try { execSync(`pkill -9 -P ${pid}`, { stdio: 'ignore' }); }561catch { }562}563child.kill('SIGKILL');564resolve(undefined);565}, 30_000);566child.once('exit', () => { clearTimeout(timer); resolve(undefined); });567});568// Disconnect CDP after the process has exited569await browser.close().catch(() => { });570// Kill crashpad handler — it self-daemonizes and outlives the571// parent. Wait briefly for it to detach, then kill by pattern.572await new Promise(r => setTimeout(r, 500));573try { execSync('pkill -9 -f crashpad_handler.*vscode-chat-simulation', { stdio: 'ignore' }); }574catch { }575},576};577}578579// -- Statistics --------------------------------------------------------------580581/**582* @param {number[]} values583*/584function median(values) {585const sorted = [...values].sort((a, b) => a - b);586const mid = Math.floor(sorted.length / 2);587return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;588}589590/**591* Remove outliers using IQR method.592* @param {number[]} values593* @returns {number[]}594*/595function removeOutliers(values) {596if (values.length < 4) { return values; }597const sorted = [...values].sort((a, b) => a - b);598const q1 = sorted[Math.floor(sorted.length * 0.25)];599const q3 = sorted[Math.floor(sorted.length * 0.75)];600const iqr = q3 - q1;601const lo = q1 - 1.5 * iqr;602const hi = q3 + 1.5 * iqr;603return sorted.filter(v => v >= lo && v <= hi);604}605606/**607* Regularized incomplete beta function I_x(a, b) via continued fraction.608* Used for computing t-distribution CDF / p-values.609* @param {number} x610* @param {number} a611* @param {number} b612* @returns {number}613*/614function betaIncomplete(x, a, b) {615if (x <= 0) { return 0; }616if (x >= 1) { return 1; }617// Use symmetry relation when x > (a+1)/(a+b+2) for better convergence618if (x > (a + 1) / (a + b + 2)) {619return 1 - betaIncomplete(1 - x, b, a);620}621// Log-beta via Stirling: lnBeta(a,b) = lnGamma(a)+lnGamma(b)-lnGamma(a+b)622const lnBeta = lnGamma(a) + lnGamma(b) - lnGamma(a + b);623const front = Math.exp(Math.log(x) * a + Math.log(1 - x) * b - lnBeta) / a;624// Lentz's continued fraction625const maxIter = 200;626const eps = 1e-14;627let c = 1, d = 1 - (a + b) * x / (a + 1);628if (Math.abs(d) < eps) { d = eps; }629d = 1 / d;630let result = d;631for (let m = 1; m <= maxIter; m++) {632// Even step633let num = m * (b - m) * x / ((a + 2 * m - 1) * (a + 2 * m));634d = 1 + num * d; if (Math.abs(d) < eps) { d = eps; } d = 1 / d;635c = 1 + num / c; if (Math.abs(c) < eps) { c = eps; }636result *= d * c;637// Odd step638num = -(a + m) * (a + b + m) * x / ((a + 2 * m) * (a + 2 * m + 1));639d = 1 + num * d; if (Math.abs(d) < eps) { d = eps; } d = 1 / d;640c = 1 + num / c; if (Math.abs(c) < eps) { c = eps; }641const delta = d * c;642result *= delta;643if (Math.abs(delta - 1) < eps) { break; }644}645return front * result;646}647648/**649* Log-gamma via Lanczos approximation.650* @param {number} z651* @returns {number}652*/653function lnGamma(z) {654const g = 7;655const coef = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,656771.32342877765313, -176.61502916214059, 12.507343278686905,657-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];658if (z < 0.5) {659return Math.log(Math.PI / Math.sin(Math.PI * z)) - lnGamma(1 - z);660}661z -= 1;662let x = coef[0];663for (let i = 1; i < g + 2; i++) { x += coef[i] / (z + i); }664const t = z + g + 0.5;665return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);666}667668/**669* Two-tailed p-value from t-distribution.670* @param {number} t - t-statistic671* @param {number} df - degrees of freedom672* @returns {number}673*/674function tDistPValue(t, df) {675const x = df / (df + t * t);676return betaIncomplete(x, df / 2, 0.5);677}678679/**680* Welch's t-test for two independent samples (unequal variance).681* @param {number[]} a - Sample 1 (e.g., baseline values)682* @param {number[]} b - Sample 2 (e.g., current values)683* @returns {{ t: number, df: number, pValue: number, significant: boolean, confidence: string } | null}684*/685function welchTTest(a, b) {686if (a.length < 2 || b.length < 2) { return null; }687const meanA = a.reduce((s, v) => s + v, 0) / a.length;688const meanB = b.reduce((s, v) => s + v, 0) / b.length;689const varA = a.reduce((s, v) => s + (v - meanA) ** 2, 0) / (a.length - 1);690const varB = b.reduce((s, v) => s + (v - meanB) ** 2, 0) / (b.length - 1);691const seA = varA / a.length;692const seB = varB / b.length;693const seDiff = Math.sqrt(seA + seB);694if (seDiff === 0) { return null; }695const t = (meanB - meanA) / seDiff;696// Welch-Satterthwaite degrees of freedom697const df = (seA + seB) ** 2 / ((seA ** 2) / (a.length - 1) + (seB ** 2) / (b.length - 1));698const pValue = tDistPValue(t, df);699const significant = pValue < 0.05;700let confidence;701if (pValue < 0.01) { confidence = 'high'; }702else if (pValue < 0.05) { confidence = 'medium'; }703else if (pValue < 0.1) { confidence = 'low'; }704else { confidence = 'none'; }705return { t: Math.round(t * 100) / 100, df: Math.round(df * 10) / 10, pValue: Math.round(pValue * 1000) / 1000, significant, confidence };706}707708/**709* Compute robust stats for a metric array.710* @param {number[]} raw711*/712function robustStats(raw) {713const valid = raw.filter(v => v >= 0);714if (valid.length === 0) { return null; }715const cleaned = removeOutliers(valid);716if (cleaned.length === 0) { return null; }717const sorted = [...cleaned].sort((a, b) => a - b);718const med = median(sorted);719const p95 = sorted[Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1)];720const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;721const variance = sorted.reduce((a, b) => a + (b - mean) ** 2, 0) / sorted.length;722const stddev = Math.sqrt(variance);723const cv = mean > 0 ? stddev / mean : 0;724return {725median: Math.round(med * 100) / 100,726p95: Math.round(p95 * 100) / 100,727min: sorted[0],728max: sorted[sorted.length - 1],729mean: Math.round(mean * 100) / 100,730stddev: Math.round(stddev * 100) / 100,731cv: Math.round(cv * 1000) / 1000,732n: sorted.length,733nOutliers: valid.length - cleaned.length,734};735}736737/**738* Simple linear regression slope (y per unit x).739* @param {number[]} values740*/741function linearRegressionSlope(values) {742const n = values.length;743if (n < 2) { return 0; }744let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;745for (let i = 0; i < n; i++) {746sumX += i;747sumY += values[i];748sumXY += i * values[i];749sumX2 += i * i;750}751return (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);752}753754/**755* Format a single metric line for console output.756* @param {number[]} values757* @param {string} label758* @param {string} unit759*/760function summarize(values, label, unit) {761const s = robustStats(values);762if (!s) { return ` ${label}: (no data)`; }763const cv = s.cv > 0.15 ? ` cv=${(s.cv * 100).toFixed(0)}%⚠` : ` cv=${(s.cv * 100).toFixed(0)}%`;764const outliers = s.nOutliers > 0 ? ` (${s.nOutliers} outlier${s.nOutliers > 1 ? 's' : ''} removed)` : '';765return ` ${label}: median=${s.median}${unit}, p95=${s.p95}${unit},${cv}${outliers} [n=${s.n}]`;766}767768/**769* Compute duration between two chat perf marks.770* @param {Array<{name: string, startTime: number}>} marks771* @param {string} from772* @param {string} to773*/774function markDuration(marks, from, to) {775const fromMark = marks.find(m => m.name.endsWith('/' + from));776const toMark = marks.find(m => m.name.endsWith('/' + to));777if (fromMark && toMark) {778return toMark.startTime - fromMark.startTime;779}780return -1;781}782783/** @type {Array<[string, string, string]>} */784const METRIC_DEFS = [785['timeToFirstToken', 'timing', 'ms'],786['timeToComplete', 'timing', 'ms'],787['timeToRenderComplete', 'timing', 'ms'],788['timeToUIUpdated', 'timing', 'ms'],789['instructionCollectionTime', 'timing', 'ms'],790['agentInvokeTime', 'timing', 'ms'],791['heapDelta', 'memory', 'MB'],792['heapDeltaPostGC', 'memory', 'MB'],793['gcDurationMs', 'memory', 'ms'],794['layoutCount', 'rendering', ''],795['layoutDurationMs', 'rendering', 'ms'],796['recalcStyleCount', 'rendering', ''],797['forcedReflowCount', 'rendering', ''],798['longTaskCount', 'rendering', ''],799['longAnimationFrameCount', 'rendering', ''],800['longAnimationFrameTotalMs', 'rendering', 'ms'],801['frameCount', 'rendering', ''],802['compositeLayers', 'rendering', ''],803['paintCount', 'rendering', ''],804['extHostHeapUsedBefore', 'extHost', 'MB'],805['extHostHeapUsedAfter', 'extHost', 'MB'],806['extHostHeapDelta', 'extHost', 'MB'],807['extHostHeapDeltaPostGC', 'extHost', 'MB'],808];809810module.exports = {811ROOT,812DATA_DIR,813METRIC_DEFS,814loadConfig,815getElectronPath,816getRepoRoot,817isVersionString,818resolveBuild,819preseedStorage,820buildEnv,821buildArgs,822writeSettings,823prepareRunDir,824median,825removeOutliers,826robustStats,827welchTTest,828linearRegressionSlope,829summarize,830markDuration,831launchVSCode,832getNextExtHostInspectPort,833connectToExtHostInspector,834};835836837