Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/scripts/chat-simulation/merge-ci-summary.js
13379 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
* Merge per-group perf results into a single unified CI summary.
10
*
11
* Called by the CI report job after all matrix groups have finished.
12
* Reads results.json and baseline-*.json from each group directory,
13
* merges all scenarios into one combined report, and writes a single
14
* ci-summary.md file.
15
*
16
* Usage:
17
* node scripts/chat-simulation/merge-ci-summary.js \
18
* --results-dir perf-results \
19
* --output ci-summary.md \
20
* [--leak-summary leak-results/.chat-simulation-data/ci-summary-leak.md] \
21
* [--threshold 0.2]
22
*/
23
24
const fs = require('fs');
25
const path = require('path');
26
const { welchTTest, loadConfig } = require('./common/utils');
27
28
// -- CLI args ----------------------------------------------------------------
29
30
function parseArgs() {
31
const args = process.argv.slice(2);
32
const opts = {
33
resultsDir: '',
34
output: '',
35
/** @type {string | undefined} */
36
leakSummary: undefined,
37
threshold: 0.2,
38
/** @type {Record<string, number | string>} */
39
metricThresholds: {},
40
};
41
for (let i = 0; i < args.length; i++) {
42
switch (args[i]) {
43
case '--results-dir': opts.resultsDir = args[++i]; break;
44
case '--output': opts.output = args[++i]; break;
45
case '--leak-summary': opts.leakSummary = args[++i]; break;
46
case '--threshold': opts.threshold = parseFloat(args[++i]); break;
47
case '--help': case '-h':
48
console.log([
49
'Merge per-group perf results into a single CI summary.',
50
'',
51
'Options:',
52
' --results-dir <dir> Directory containing perf-results-* or perf-summary-* subdirs',
53
' --output <path> Output path for ci-summary.md',
54
' --leak-summary <path> Path to ci-summary-leak.md (optional)',
55
' --threshold <frac> Regression threshold fraction (default: 0.2)',
56
].join('\n'));
57
process.exit(0);
58
}
59
}
60
if (!opts.resultsDir || !opts.output) {
61
console.error('Required: --results-dir and --output');
62
process.exit(1);
63
}
64
return opts;
65
}
66
67
// -- Merge logic -------------------------------------------------------------
68
69
/**
70
* Find all results.json and baseline-*.json files across group directories,
71
* merge scenarios into a single combined report.
72
* @param {string} resultsDir
73
*/
74
function mergeResults(resultsDir) {
75
let groupDirs = fs.readdirSync(resultsDir)
76
.filter(d => d.startsWith('perf-results-') || d.startsWith('perf-summary-'))
77
.map(d => path.join(resultsDir, d))
78
.filter(d => fs.statSync(d).isDirectory());
79
80
// Fallback: when download-artifact extracts a single artifact directly into
81
// resultsDir (no artifact-named subdirectory), treat resultsDir itself as the
82
// sole group directory if it contains a .chat-simulation-data folder.
83
if (groupDirs.length === 0) {
84
const simDataDir = path.join(resultsDir, '.chat-simulation-data');
85
if (fs.existsSync(simDataDir) && fs.statSync(simDataDir).isDirectory()) {
86
console.log(`No named subdirectories found; using ${resultsDir} directly as single group`);
87
groupDirs = [resultsDir];
88
} else {
89
console.error(`No perf-results-* or perf-summary-* directories found in ${resultsDir}`);
90
return null;
91
}
92
}
93
94
/** @type {Record<string, any>} */
95
const mergedScenarios = {};
96
/** @type {Record<string, any>} */
97
const mergedBaselineScenarios = {};
98
let runsPerScenario = 0;
99
let platform = 'linux';
100
/** @type {string | undefined} */
101
let buildMode;
102
/** @type {string | undefined} */
103
let baselineBuildVersion;
104
/** @type {string | undefined} */
105
let threshold;
106
107
// Read per-metric thresholds from config.jsonc (same source as the perf script)
108
const perfConfig = loadConfig('perfRegression');
109
/** @type {Record<string, number | string>} */
110
const metricThresholds = perfConfig.metricThresholds ?? {};
111
112
for (const groupDir of groupDirs) {
113
// Find results.json (may be in a timestamped subdir under .chat-simulation-data)
114
const simDataDir = path.join(groupDir, '.chat-simulation-data');
115
if (!fs.existsSync(simDataDir)) { continue; }
116
117
// Search for results.json in timestamped subdirs
118
const subdirs = fs.readdirSync(simDataDir).filter(d => {
119
const full = path.join(simDataDir, d);
120
return fs.statSync(full).isDirectory() && /^\d{4}-/.test(d);
121
});
122
123
for (const subdir of subdirs) {
124
const resultsPath = path.join(simDataDir, subdir, 'results.json');
125
if (fs.existsSync(resultsPath)) {
126
const results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'));
127
runsPerScenario = results.runsPerScenario || runsPerScenario;
128
platform = results.platform || platform;
129
buildMode = results.buildMode || buildMode;
130
for (const [scenario, data] of Object.entries(results.scenarios || {})) {
131
mergedScenarios[scenario] = data;
132
}
133
}
134
135
// Find baseline-*.json in the same dir
136
const baselineFiles = fs.readdirSync(path.join(simDataDir, subdir))
137
.filter(f => f.startsWith('baseline-') && f.endsWith('.json'));
138
for (const bf of baselineFiles) {
139
const baseline = JSON.parse(fs.readFileSync(path.join(simDataDir, subdir, bf), 'utf-8'));
140
baselineBuildVersion = baseline.baselineBuildVersion || baselineBuildVersion;
141
for (const [scenario, data] of Object.entries(baseline.scenarios || {})) {
142
mergedBaselineScenarios[scenario] = data;
143
}
144
}
145
}
146
147
// Also check for baseline cached at top-level .chat-simulation-data
148
const topBaselines = fs.readdirSync(simDataDir)
149
.filter(f => f.startsWith('baseline-') && f.endsWith('.json'));
150
for (const bf of topBaselines) {
151
const baseline = JSON.parse(fs.readFileSync(path.join(simDataDir, bf), 'utf-8'));
152
baselineBuildVersion = baseline.baselineBuildVersion || baselineBuildVersion;
153
for (const [scenario, data] of Object.entries(baseline.scenarios || {})) {
154
mergedBaselineScenarios[scenario] = data;
155
}
156
}
157
158
// Read threshold/metricThresholds from the group's ci-summary or config
159
const ciSummaryPath = path.join(simDataDir, 'ci-summary.md');
160
if (fs.existsSync(ciSummaryPath)) {
161
const content = fs.readFileSync(ciSummaryPath, 'utf-8');
162
const thresholdMatch = content.match(/Regression threshold\*\* \| (\d+)%/);
163
if (thresholdMatch) {
164
threshold = thresholdMatch[1];
165
}
166
}
167
}
168
169
const mergedReport = {
170
timestamp: new Date().toISOString(),
171
platform,
172
runsPerScenario,
173
buildMode,
174
scenarios: mergedScenarios,
175
};
176
177
const mergedBaseline = Object.keys(mergedBaselineScenarios).length > 0
178
? { baselineBuildVersion, scenarios: mergedBaselineScenarios }
179
: null;
180
181
return { report: mergedReport, baseline: mergedBaseline, baselineBuildVersion, threshold: threshold ? parseInt(threshold, 10) / 100 : undefined, metricThresholds };
182
}
183
184
// -- Summary generation (unified, single-header format) ----------------------
185
186
const GITHUB_REPO = 'https://github.com/microsoft/vscode';
187
188
/** @param {string} label */
189
function formatBuildLink(label) {
190
if (/^[0-9a-f]{7,40}$/.test(label)) {
191
return `[\`${label.substring(0, 7)}\`](${GITHUB_REPO}/commit/${label})`;
192
}
193
if (/^\d+\.\d+\.\d+/.test(label)) {
194
return `[\`${label}\`](${GITHUB_REPO}/releases/tag/${label})`;
195
}
196
return `\`${label}\``;
197
}
198
199
/**
200
* @param {string} base
201
* @param {string} test
202
*/
203
function formatCompareLink(base, test) {
204
const isRef = (/** @type {string} */ v) => /^[0-9a-f]{7,40}$/.test(v) || /^\d+\.\d+\.\d+/.test(v);
205
if (!isRef(base) || !isRef(test)) { return ''; }
206
return `[compare](${GITHUB_REPO}/compare/${base}...${test})`;
207
}
208
209
/**
210
* @param {{ type: string, value: number }} threshold
211
* @param {number} change
212
* @param {number} absoluteDelta
213
*/
214
function exceedsThreshold(threshold, change, absoluteDelta) {
215
if (threshold.type === 'absolute') { return absoluteDelta > threshold.value; }
216
return change > threshold.value;
217
}
218
219
/**
220
* @param {{ threshold: number, metricThresholds?: Record<string, number | string> }} opts
221
* @param {string} metric
222
*/
223
function getMetricThreshold(opts, metric) {
224
const raw = opts.metricThresholds?.[metric];
225
if (raw !== undefined) {
226
const num = typeof raw === 'number' ? raw : parseFloat(/** @type {string} */(raw));
227
return typeof raw === 'number' ? { type: 'fraction', value: num } : { type: 'absolute', value: num };
228
}
229
return { type: 'fraction', value: opts.threshold };
230
}
231
232
/** @param {number} v */
233
function round2(v) { return Math.round(v * 100) / 100; }
234
235
/**
236
* Generate a unified Markdown summary for all scenarios.
237
*
238
* @param {Record<string, any>} jsonReport
239
* @param {Record<string, any> | null} baseline
240
* @param {{ threshold: number, metricThresholds?: Record<string, number | string>, runs: number, baselineBuild?: string, build?: string, hasLeakFailure?: boolean }} opts
241
*/
242
function generateUnifiedSummary(jsonReport, baseline, opts) {
243
const baseLabel = opts.baselineBuild || 'baseline';
244
const testBuildMode = jsonReport.buildMode || 'dev';
245
const testLabel = testBuildMode === 'dev' ? 'dev (local)'
246
: testBuildMode === 'production' ? 'production (local)'
247
: opts.build || testBuildMode;
248
const baseLink = formatBuildLink(baseLabel);
249
const testLink = formatBuildLink(testLabel);
250
const compareLink = formatCompareLink(baseLabel, testLabel);
251
252
const allMetrics = [
253
['timeToFirstToken', 'timing', 'ms'],
254
['timeToComplete', 'timing', 'ms'],
255
['layoutCount', 'rendering', ''],
256
['recalcStyleCount', 'rendering', ''],
257
['forcedReflowCount', 'rendering', ''],
258
['longTaskCount', 'rendering', ''],
259
['longAnimationFrameCount', 'rendering', ''],
260
['longAnimationFrameTotalMs', 'rendering', 'ms'],
261
['frameCount', 'rendering', ''],
262
['compositeLayers', 'rendering', ''],
263
['paintCount', 'rendering', ''],
264
['heapDelta', 'memory', 'MB'],
265
['heapDeltaPostGC', 'memory', 'MB'],
266
['gcDurationMs', 'memory', 'ms'],
267
['extHostHeapDelta', 'extHost', 'MB'],
268
['extHostHeapDeltaPostGC', 'extHost', 'MB'],
269
];
270
const regressionMetricNames = new Set([
271
'timeToFirstToken', 'timeToComplete', 'layoutCount', 'recalcStyleCount',
272
'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount',
273
]);
274
275
const lines = [];
276
const scenarios = Object.keys(jsonReport.scenarios);
277
278
// -- Collect verdicts ------------------------------------------------
279
/** @type {Map<string, { metric: string, verdict: string, change: number, pValue: string, basStr: string, curStr: string }[]>} */
280
const scenarioVerdicts = new Map();
281
let totalRegressions = 0;
282
let totalImprovements = 0;
283
284
for (const scenario of scenarios) {
285
const current = jsonReport.scenarios[scenario];
286
const base = baseline?.scenarios?.[scenario];
287
/** @type {{ metric: string, verdict: string, change: number, pValue: string, basStr: string, curStr: string }[]} */
288
const verdicts = [];
289
290
if (base) {
291
for (const [metric, group, unit] of allMetrics) {
292
const cur = current[group]?.[metric];
293
const bas = base[group]?.[metric];
294
if (!cur || !bas || bas.median === null || bas.median === undefined) { continue; }
295
296
const change = bas.median !== 0 ? (cur.median - bas.median) / bas.median : 0;
297
const isRegressionMetric = regressionMetricNames.has(metric);
298
299
const curRaw = (current.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0);
300
const basRaw = (base.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0);
301
const ttest = welchTTest(basRaw, curRaw);
302
const pStr = ttest ? `${ttest.pValue}` : 'n/a';
303
304
const metricThreshold = getMetricThreshold(opts, metric);
305
const absoluteDelta = cur.median - bas.median;
306
let verdict = '';
307
if (isRegressionMetric) {
308
if (exceedsThreshold(metricThreshold, change, absoluteDelta)) {
309
if (!ttest || ttest.significant) {
310
verdict = 'REGRESSION';
311
totalRegressions++;
312
} else {
313
verdict = 'noise';
314
}
315
} else if (exceedsThreshold(metricThreshold, -change, -absoluteDelta) && ttest?.significant) {
316
verdict = 'improved';
317
totalImprovements++;
318
} else {
319
verdict = 'ok';
320
}
321
} else {
322
verdict = 'info';
323
}
324
325
const basStr = `${bas.median}${unit} \xb1${bas.stddev}${unit}`;
326
const curStr = `${cur.median}${unit} \xb1${cur.stddev}${unit}`;
327
verdicts.push({ metric, verdict, change, pValue: pStr, basStr, curStr });
328
}
329
}
330
scenarioVerdicts.set(scenario, verdicts);
331
}
332
333
// -- Header ----------------------------------------------------------
334
const hasRegressions = totalRegressions > 0;
335
const hasLeakFailure = !!opts.hasLeakFailure;
336
const hasFailed = hasRegressions || hasLeakFailure;
337
const verdictIcon = hasFailed ? '\u274C' : '\u2705';
338
const verdictParts = [];
339
if (hasRegressions && totalImprovements > 0) {
340
verdictParts.push(`${totalRegressions} regression(s), ${totalImprovements} improvement(s)`);
341
} else if (hasRegressions) {
342
verdictParts.push(`${totalRegressions} regression(s) detected`);
343
} else if (totalImprovements > 0) {
344
verdictParts.push(`No regressions \u2014 ${totalImprovements} improvement(s)`);
345
} else {
346
verdictParts.push('No significant changes');
347
}
348
if (hasLeakFailure) {
349
verdictParts.push('memory leak detected');
350
}
351
const verdictText = verdictParts.join('; ');
352
353
lines.push(`# ${verdictIcon} Chat Performance: ${verdictText}`);
354
lines.push('');
355
lines.push(`| | |`);
356
lines.push(`|---|---|`);
357
lines.push(`| **Baseline** | ${baseLink} |`);
358
lines.push(`| **Test** | ${testLink} |`);
359
if (compareLink) {
360
lines.push(`| **Diff** | ${compareLink} |`);
361
}
362
lines.push(`| **Runs per scenario** | ${opts.runs} |`);
363
const overrides = Object.entries(opts.metricThresholds || {}).filter(([, v]) => {
364
const parsed = typeof v === 'number' ? { type: 'fraction', value: v } : { type: 'absolute', value: parseFloat(/** @type {string} */(v)) };
365
return parsed.type !== 'fraction' || parsed.value !== opts.threshold;
366
});
367
if (overrides.length > 0) {
368
const overrideStr = overrides.map(([k, v]) => {
369
if (typeof v === 'number') {
370
return `${k}: ${(v * 100).toFixed(0)}%`;
371
}
372
return `${k}: ${v}`;
373
}).join(', ');
374
lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% (${overrideStr}) |`);
375
} else {
376
lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% |`);
377
}
378
lines.push(`| **Scenarios** | ${scenarios.length} |`);
379
lines.push(`| **Platform** | ${jsonReport.platform || 'linux'} / x64 |`);
380
lines.push('');
381
382
// -- Overview table --------------------------------------------------
383
lines.push('## Overview');
384
lines.push('');
385
lines.push('| Scenario | TTFT | Complete | Layouts | Styles | LoAF | Verdict |');
386
lines.push('|----------|-----:|---------:|--------:|-------:|-----:|:-------:|');
387
388
for (const scenario of scenarios) {
389
const verdicts = scenarioVerdicts.get(scenario) || [];
390
const get = (/** @type {string} */ m) => verdicts.find(v => v.metric === m);
391
392
const ttft = get('timeToFirstToken');
393
const complete = get('timeToComplete');
394
const layouts = get('layoutCount');
395
const styles = get('recalcStyleCount');
396
const loaf = get('longAnimationFrameCount');
397
398
const fmtCell = (/** @type {{ change: number } | undefined} */ v) => {
399
if (!v) { return '\u2014'; }
400
return `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(0)}%`;
401
};
402
403
const keyVerdicts = [ttft, complete, layouts, styles, loaf].filter(Boolean);
404
const hasRegression = keyVerdicts.some(v => v?.verdict === 'REGRESSION');
405
const hasImproved = keyVerdicts.some(v => v?.verdict === 'improved');
406
const rowVerdict = hasRegression ? '\u274C' : hasImproved ? '\u2B06\uFE0F' : '\u2705';
407
408
lines.push(`| ${scenario} | ${fmtCell(ttft)} | ${fmtCell(complete)} | ${fmtCell(layouts)} | ${fmtCell(styles)} | ${fmtCell(loaf)} | ${rowVerdict} |`);
409
}
410
lines.push('');
411
412
// -- Regressions & improvements (compact table) ----------------------
413
const notableRows = [];
414
for (const scenario of scenarios) {
415
const verdicts = scenarioVerdicts.get(scenario) || [];
416
for (const v of verdicts) {
417
if (v.verdict === 'REGRESSION' || v.verdict === 'improved') {
418
notableRows.push({ scenario, ...v });
419
}
420
}
421
}
422
423
if (notableRows.length > 0) {
424
lines.push('## Regressions & Improvements');
425
lines.push('');
426
427
lines.push('| Scenario | Metric | Baseline | Test | Change | p-value | |');
428
lines.push('|----------|--------|----------|------|-------:|--------:|:-:|');
429
for (const r of notableRows) {
430
const pct = `${r.change > 0 ? '+' : ''}${(r.change * 100).toFixed(1)}%`;
431
const icon = r.verdict === 'REGRESSION' ? '\u274C' : '\u2B06\uFE0F';
432
lines.push(`| ${r.scenario} | ${r.metric} | ${r.basStr} | ${r.curStr} | ${pct} | ${r.pValue} | ${icon} |`);
433
}
434
lines.push('');
435
}
436
437
// -- Full details (collapsible) --------------------------------------
438
lines.push('<details><summary>Full metric details per scenario</summary>');
439
lines.push('');
440
441
for (const scenario of scenarios) {
442
const verdicts = scenarioVerdicts.get(scenario) || [];
443
const base = baseline?.scenarios?.[scenario];
444
445
lines.push(`### ${scenario}`);
446
lines.push('');
447
448
if (!base) {
449
const current = jsonReport.scenarios[scenario];
450
lines.push('> No baseline data for this scenario.');
451
lines.push('');
452
lines.push('| Metric | Value | StdDev | CV | n |');
453
lines.push('|--------|------:|-------:|---:|--:|');
454
for (const [metric, group, unit] of allMetrics) {
455
const cur = current[group]?.[metric];
456
if (!cur) { continue; }
457
lines.push(`| ${metric} | ${cur.median}${unit} | \xb1${cur.stddev}${unit} | ${(cur.cv * 100).toFixed(0)}% | ${cur.n} |`);
458
}
459
lines.push('');
460
continue;
461
}
462
463
lines.push('| Metric | Baseline | Test | Change | p-value | Verdict |');
464
lines.push('|--------|----------|------|--------|---------|---------|');
465
for (const v of verdicts) {
466
const pct = `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(1)}%`;
467
let verdictDisplay = v.verdict;
468
if (v.verdict === 'REGRESSION') { verdictDisplay = '\u274C REGRESSION'; }
469
else if (v.verdict === 'improved') { verdictDisplay = '\u2B06\uFE0F improved'; }
470
else if (v.verdict === 'ok') { verdictDisplay = '\u2705 ok'; }
471
else if (v.verdict === 'noise') { verdictDisplay = '\uD83C\uDF2B\uFE0F noise'; }
472
else if (v.verdict === 'info') { verdictDisplay = '\u2139\uFE0F'; }
473
lines.push(`| ${v.metric} | ${v.basStr} | ${v.curStr} | ${pct} | ${v.pValue} | ${verdictDisplay} |`);
474
}
475
lines.push('');
476
}
477
lines.push('</details>');
478
lines.push('');
479
480
// -- Raw run data (collapsible) --------------------------------------
481
lines.push('<details><summary>Raw run data</summary>');
482
lines.push('');
483
for (const scenario of scenarios) {
484
const current = jsonReport.scenarios[scenario];
485
lines.push(`### ${scenario}`);
486
lines.push('');
487
lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) |');
488
lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|');
489
const runs = current.rawRuns || [];
490
for (let i = 0; i < runs.length; i++) {
491
const r = runs[i];
492
lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${round2(r.longAnimationFrameTotalMs ?? 0) || '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} |`);
493
}
494
lines.push('');
495
}
496
if (baseline) {
497
for (const scenario of scenarios) {
498
const base = baseline.scenarios?.[scenario];
499
if (!base) { continue; }
500
lines.push(`### ${scenario} (baseline)`);
501
lines.push('');
502
lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) |');
503
lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|');
504
const runs = base.rawRuns || [];
505
for (let i = 0; i < runs.length; i++) {
506
const r = runs[i];
507
lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${round2(r.longAnimationFrameTotalMs ?? 0) || '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} |`);
508
}
509
lines.push('');
510
}
511
}
512
lines.push('</details>');
513
lines.push('');
514
515
return lines.join('\n');
516
}
517
518
// -- Main --------------------------------------------------------------------
519
520
function main() {
521
const opts = parseArgs();
522
const merged = mergeResults(opts.resultsDir);
523
524
if (!merged) {
525
const fallback = '\u26A0\uFE0F No perf results found to merge. Check perf-output.log artifacts.\n';
526
fs.writeFileSync(opts.output, fallback);
527
console.log('[merge] No results found.');
528
process.exit(0);
529
}
530
531
const { report, baseline, baselineBuildVersion } = merged;
532
const scenarioCount = Object.keys(report.scenarios).length;
533
console.log(`[merge] Merged ${scenarioCount} scenarios from ${fs.readdirSync(opts.resultsDir).filter(d => d.startsWith('perf-results-') || d.startsWith('perf-summary-')).length} groups`);
534
if (baseline) {
535
console.log(`[merge] Baseline: ${baselineBuildVersion || 'unknown'} (${Object.keys(baseline.scenarios).length} scenarios)`);
536
}
537
538
// Read leak summary early so we can reflect it in the header verdict
539
let leakSummaryContent = '';
540
let hasLeakFailure = false;
541
if (opts.leakSummary && fs.existsSync(opts.leakSummary)) {
542
leakSummaryContent = fs.readFileSync(opts.leakSummary, 'utf-8');
543
hasLeakFailure = leakSummaryContent.includes('\u274C');
544
console.log(`[merge] Leak summary found (failure: ${hasLeakFailure})`);
545
}
546
547
const summary = generateUnifiedSummary(report, baseline, {
548
threshold: merged.threshold || opts.threshold,
549
metricThresholds: merged.metricThresholds,
550
runs: report.runsPerScenario,
551
baselineBuild: baselineBuildVersion,
552
build: process.env.TEST_COMMIT || undefined,
553
hasLeakFailure,
554
});
555
556
// Append leak summary if available
557
let fullSummary = summary;
558
if (leakSummaryContent) {
559
fullSummary += '\n' + leakSummaryContent;
560
}
561
562
fs.writeFileSync(opts.output, fullSummary);
563
console.log(`[merge] Summary written to ${opts.output}`);
564
}
565
566
main();
567
568