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