@progen-labs/spectra 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@ import { attachChrome, launchChrome } from './chrome.js';
18
18
  import { FRONTEND_ROOT, REPORTS_DIR, SOURCE_ROOTS, FRAMEWORK } from './config.js';
19
19
  import { SourceIndex } from './analysis/locate.js';
20
20
  import { gitInfo } from './git.js';
21
+ import { REPORT_SCHEMA_VERSION } from '../shared/result.js';
21
22
  import { buildSignInUrl, looksSignedOut } from './signin.js';
22
23
 
23
24
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -417,7 +418,7 @@ export class PerfSession extends EventEmitter {
417
418
  .toISOString()
418
419
  .replace(/[-:]/g, '')
419
420
  .replace(/\..+/, '') + `-${rec.mode}`;
420
- const report = await buildReport({
421
+ const built = await buildReport({
421
422
  events,
422
423
  probe,
423
424
  metrics: { before: rec.metricsBefore, after: metricsAfter, dom },
@@ -438,6 +439,8 @@ export class PerfSession extends EventEmitter {
438
439
  navUrl: this.navigation?.url ?? '',
439
440
  },
440
441
  });
442
+ // versioned so editors, CI and agents can tell which format they read
443
+ const report = { schemaVersion: REPORT_SCHEMA_VERSION, ...built };
441
444
  this.lastTrace = { id: report.id, text: traceText };
442
445
  report.pageView = pageView
443
446
  ? { ...pageView.layout, hasImage: true }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * spectra reports [--json] [--limit n] — the saved reports, newest first, so
3
+ * their ids can be passed to `spectra compare` or opened in the dashboard.
4
+ */
5
+ import { listReports } from './recorder.js';
6
+ import { REPORTS_DIR } from './config.js';
7
+ import { intOption, parseArgs } from './args.js';
8
+ import { EXIT, SPECTRA_VERSION, exitAfterFlush, writeResult } from './output.js';
9
+ import { buildReportsResult } from '../shared/result.js';
10
+
11
+ const { opt, flag } = parseArgs(process.argv.slice(2));
12
+ let limit;
13
+ try {
14
+ limit = intOption(opt('limit'), 'limit', { max: 10_000, fallback: 20 });
15
+ } catch (err) {
16
+ console.error(`spectra: ${err.message}`);
17
+ process.exit(EXIT.ERROR);
18
+ }
19
+
20
+ const reports = listReports().slice(0, limit);
21
+ if (flag('json')) {
22
+ writeResult(buildReportsResult({ reports, dir: REPORTS_DIR, spectraVersion: SPECTRA_VERSION }), { json: true });
23
+ } else if (!reports.length) {
24
+ console.log(`No reports yet in ${REPORTS_DIR}. Create one with: spectra performance --quick`);
25
+ } else {
26
+ console.log(`${'Report id'.padEnd(26)}${'Mode'.padEnd(7)}${'Findings'.padStart(9)}${'Critical'.padStart(10)} URL`);
27
+ for (const r of reports)
28
+ console.log(`${r.id.padEnd(26)}${String(r.mode).padEnd(7)}${String(r.findings).padStart(9)}${String(r.critical).padStart(10)} ${r.url}`);
29
+ console.log(`\n${reports.length} shown · ${REPORTS_DIR}`);
30
+ }
31
+ exitAfterFlush(EXIT.OK);
@@ -0,0 +1,226 @@
1
+ // Named test-*.js (not *.test.js): the repo's jest testRegex covers tools/.
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { test } from 'node:test';
8
+ import { fileURLToPath } from 'node:url';
9
+ import {
10
+ BUDGET_KEYS,
11
+ REPORT_SCHEMA_VERSION,
12
+ RESULT_SCHEMA_VERSION,
13
+ SUMMARY_METRICS,
14
+ briefFinding,
15
+ buildCompareResult,
16
+ buildEvaluationResult,
17
+ buildProfileResult,
18
+ checkBudgets,
19
+ isRegression,
20
+ representativeRun,
21
+ summarize,
22
+ summarizeRuns,
23
+ validateBudgets,
24
+ } from '../shared/result.js';
25
+
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const readSchema = name => JSON.parse(fs.readFileSync(path.join(ROOT, 'schema', name), 'utf8'));
28
+
29
+ const finding = (over = {}) => ({
30
+ id: 'slow-component-Row-src/Row.jsx',
31
+ severity: 'critical',
32
+ category: 'React',
33
+ title: 'Row is slow to render',
34
+ detail: 'src/Row.jsx:3.',
35
+ impactMs: 1442.94,
36
+ file: 'src/Row.jsx',
37
+ fix: 'Memoise it.',
38
+ culprits: [{ fn: 'Row', file: 'src/Row.jsx', line: 3, lineKind: 'exact', abs: '/p/src/Row.jsx', ms: 1442.94, note: 'slow render' }],
39
+ ...over,
40
+ });
41
+
42
+ /** A report with just enough in it for summaries, budgets and compare. */
43
+ function report(id, { tbt = 0, lcp = 100, scripting = 1000, findings = [finding()] } = {}) {
44
+ return {
45
+ schemaVersion: REPORT_SCHEMA_VERSION,
46
+ id,
47
+ createdAt: '2026-10-17T10:00:00.000Z',
48
+ meta: { url: 'http://localhost:5188/', title: 'Demo', mode: 'load', durationMs: 4000, cpuThrottle: 1, attribution: 'sourcemap', git: null, label: '' },
49
+ vitals: { tbtMs: tbt, lcpMs: lcp, fcpMs: lcp, cls: 0.0015, inpMs: null, ttfbMs: 2.4, longTaskCount: 0, maxLongTaskMs: 0 },
50
+ totals: { activeMs: scripting + 100, scriptingMs: scripting, layoutMs: 10, styleMs: 10, paintMs: 5, gcMs: 5, compileMs: 5, layoutCount: 4, styleRecalcCount: 4, forcedLayoutCount: 0, forcedStyleCount: 0, forcedMs: 0 },
51
+ memory: { domNodes: 800, listeners: 20, heapUsedMb: 9.4, heapGrowthMb: 0 },
52
+ network: { count: 10, totalEncoded: 2048 * 1024 },
53
+ react: { detected: true, hasTimings: true, version: '18.3.1', components: [], commits: { count: 60, totalMs: 100, maxMs: 10 } },
54
+ files: [],
55
+ findings,
56
+ };
57
+ }
58
+
59
+ test('summarize reads the headline numbers, rounded, with null where unmeasured', () => {
60
+ const s = summarize(report('a', { tbt: 212.96 }));
61
+ assert.equal(s.tbtMs, 213);
62
+ assert.equal(s.cls, 0.002);
63
+ assert.equal(s.inpMs, null);
64
+ assert.equal(s.transferKb, 2048);
65
+ assert.equal(s.criticalFindings, 1);
66
+ assert.deepEqual(Object.keys(s), SUMMARY_METRICS.map(m => m.key));
67
+ });
68
+
69
+ test('several runs reduce to medians with a min / max spread; the typical run supplies the findings', () => {
70
+ const runs = [report('r1', { scripting: 900 }), report('r2', { scripting: 1000 }), report('r3', { scripting: 2000 })];
71
+ const { summary, spread } = summarizeRuns(runs);
72
+ assert.equal(summary.runs, 3);
73
+ assert.equal(summary.scriptingMs, 1000);
74
+ assert.deepEqual(spread.scriptingMs, { min: 900, max: 2000 });
75
+ assert.equal(representativeRun(runs).id, 'r2');
76
+ // one run: no spread to speak of
77
+ assert.deepEqual(summarizeRuns([runs[0]]).spread, {});
78
+ });
79
+
80
+ test('briefFinding keeps what a reader acts on', () => {
81
+ const b = briefFinding(finding());
82
+ assert.equal(b.impactMs, 1442.9);
83
+ assert.equal(b.line, 3);
84
+ assert.equal(b.culprits[0].abs, '/p/src/Row.jsx');
85
+ assert.equal(b.fix, 'Memoise it.');
86
+ });
87
+
88
+ test('budgets: unknown keys and bad limits are rejected, unmeasured metrics are skipped', () => {
89
+ assert.equal(validateBudgets({ tbt: 200 }).ok, false);
90
+ assert.match(validateBudgets({ tbt: 200 }).error, /Unknown budget "tbt"/);
91
+ assert.equal(validateBudgets({ tbtMs: -1 }).ok, false);
92
+ assert.equal(validateBudgets(undefined).ok, true);
93
+ const res = checkBudgets({ tbtMs: 200, inpMs: 100, criticalFindings: 0 }, [{ name: 'median', summary: summarize(report('a', { tbt: 250 })) }]);
94
+ assert.equal(res.ok, false);
95
+ assert.equal(res.failed, 2);
96
+ assert.deepEqual(res.checks.map(c => c.status), ['fail', 'skipped', 'fail']);
97
+ assert.equal(checkBudgets({}, []), null);
98
+ });
99
+
100
+ test('profile result: versioned envelope, per-run summaries, findings marked with how many runs saw them', () => {
101
+ const runs = [report('r1'), report('r2', { findings: [] }), report('r3')];
102
+ const result = buildProfileResult({ reports: runs, fileOf: id => `/reports/${id}.json`, target: 'demo', spectraVersion: '9.9.9', budgets: { tbtMs: 200 } });
103
+ assert.equal(result.schemaVersion, RESULT_SCHEMA_VERSION);
104
+ assert.equal(result.kind, 'profile');
105
+ assert.equal(result.runs[1].file, '/reports/r2.json');
106
+ assert.equal(result.findings[0].seenIn, 2);
107
+ assert.equal(result.budgets.ok, true);
108
+ });
109
+
110
+ test('evaluation result: one summary per journey, budgets name the journey that broke them', () => {
111
+ const reports = { base: report('base'), p1: report('p1', { tbt: 400 }) };
112
+ const evaluation = {
113
+ url: 'http://localhost:5188/',
114
+ target: 'demo',
115
+ discovered: 3,
116
+ skippedDestructive: [],
117
+ baseline: { id: 'base', title: 'Initial page load', kind: 'load', ok: true },
118
+ paths: [{ id: 'p1', title: 'Click "Long task"', kind: 'interaction', ok: true }],
119
+ };
120
+ const result = buildEvaluationResult({ evaluation, readReport: id => reports[id], budgets: { tbtMs: 200 } });
121
+ assert.equal(result.journeys.length, 2);
122
+ assert.equal(result.passed, 1);
123
+ const failed = result.budgets.checks.filter(c => c.status === 'fail');
124
+ assert.deepEqual(failed.map(c => c.where), ['Click "Long task"']);
125
+ });
126
+
127
+ test('compare result wraps the shared comparison and flags regressions', () => {
128
+ const slow = [report('b1', { scripting: 1500 }), report('b2', { scripting: 1520 })];
129
+ const fast = [report('a1', { scripting: 90, findings: [] }), report('a2', { scripting: 95, findings: [] })];
130
+ const better = buildCompareResult({ before: slow, after: fast });
131
+ assert.equal(better.kind, 'compare');
132
+ assert.equal(better.comparison.verdict, 'improved');
133
+ assert.equal(better.comparison.findings.resolved.length, 1);
134
+ assert.equal(isRegression(better), false);
135
+ assert.equal(isRegression(buildCompareResult({ before: fast, after: slow })), true);
136
+ });
137
+
138
+ test('the published schemas list exactly the metrics and budgets the code produces', () => {
139
+ const result = readSchema('result.schema.json');
140
+ assert.deepEqual(Object.keys(result.$defs.summary.properties), SUMMARY_METRICS.map(m => m.key));
141
+ assert.equal(result.properties.schemaVersion.const, RESULT_SCHEMA_VERSION);
142
+ const config = readSchema('config.schema.json');
143
+ assert.deepEqual(Object.keys(config.properties.budgets.properties), BUDGET_KEYS);
144
+ // every key the schema requires is present in what the builders return
145
+ const profile = buildProfileResult({ reports: [report('r1')], budgets: {} });
146
+ for (const key of [...result.required, ...result.$defs.profile.required]) assert.ok(key in profile, `profile result lacks ${key}`);
147
+ const finding0 = profile.findings[0];
148
+ for (const key of result.$defs.finding.required) assert.ok(key in finding0, `finding lacks ${key}`);
149
+ });
150
+
151
+ // ------------------------------------------------------------------ CLI (no Chrome needed)
152
+
153
+ function workspace() {
154
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-cli-'));
155
+ const reports = path.join(dir, '.spectra', 'reports');
156
+ fs.mkdirSync(reports, { recursive: true });
157
+ const save = r => fs.writeFileSync(path.join(reports, `${r.id}.json`), JSON.stringify(r));
158
+ save(report('20261017T100000-load', { scripting: 1500 }));
159
+ save(report('20261017T100010-load', { scripting: 1510 }));
160
+ save(report('20261017T100100-load', { scripting: 90, findings: [] }));
161
+ save(report('20261017T100110-load', { scripting: 92, findings: [] }));
162
+ return { dir, reports };
163
+ }
164
+
165
+ const run = (script, args, ws) =>
166
+ spawnSync(process.execPath, [path.join(ROOT, script), ...args], {
167
+ cwd: ws.dir,
168
+ encoding: 'utf8',
169
+ env: { ...process.env, PERF_FRONTEND_DIR: ws.dir, PERF_REPORTS_DIR: ws.reports },
170
+ });
171
+
172
+ test('cli: compare by ids prints a verdict, emits JSON, and exits 2 on a regression when asked', () => {
173
+ const ws = workspace();
174
+ const before = '20261017T100000-load,20261017T100010-load';
175
+ const after = '20261017T100100-load,20261017T100110-load';
176
+ const ok = run('bin/spectra.js', ['compare', before, after, '--json'], ws);
177
+ assert.equal(ok.status, 0, ok.stderr);
178
+ const result = JSON.parse(ok.stdout);
179
+ assert.equal(result.comparison.verdict, 'improved');
180
+ assert.match(ok.stderr, /Spectra compare — IMPROVED/);
181
+
182
+ const worse = run('bin/spectra.js', ['compare', after, before, '--fail-on-regression'], ws);
183
+ assert.equal(worse.status, 2);
184
+ assert.match(worse.stdout, /REGRESSED/);
185
+ fs.rmSync(ws.dir, { recursive: true, force: true });
186
+ });
187
+
188
+ test('cli: a saved profile result works as a compare side, from any directory', () => {
189
+ const ws = workspace();
190
+ const profile = buildProfileResult({
191
+ reports: ['20261017T100000-load', '20261017T100010-load'].map(id => JSON.parse(fs.readFileSync(path.join(ws.reports, `${id}.json`), 'utf8'))),
192
+ fileOf: id => path.join(ws.reports, `${id}.json`),
193
+ });
194
+ fs.writeFileSync(path.join(ws.dir, 'before.json'), JSON.stringify(profile));
195
+ const res = run('bin/spectra.js', ['compare', 'before.json', '20261017T100100-load', '--out', 'cmp.json'], ws);
196
+ assert.equal(res.status, 0, res.stderr);
197
+ const written = JSON.parse(fs.readFileSync(path.join(ws.dir, 'cmp.json'), 'utf8'));
198
+ assert.equal(written.comparison.before.runs.length, 2);
199
+ fs.rmSync(ws.dir, { recursive: true, force: true });
200
+ });
201
+
202
+ test('cli: reports lists newest first; bad input fails fast with exit 1 and no browser', () => {
203
+ const ws = workspace();
204
+ const list = run('bin/spectra.js', ['reports', '--json'], ws);
205
+ assert.equal(list.status, 0, list.stderr);
206
+ const parsed = JSON.parse(list.stdout);
207
+ assert.equal(parsed.kind, 'reports');
208
+ assert.equal(parsed.reports[0].id, '20261017T100110-load');
209
+
210
+ const missing = run('bin/spectra.js', ['compare', 'nope', '20261017T100100-load'], ws);
211
+ assert.equal(missing.status, 1);
212
+ assert.match(missing.stderr, /no report "nope"/);
213
+
214
+ fs.writeFileSync(path.join(ws.dir, 'spectra.config.json'), JSON.stringify({ budgets: { tbt: 200 } }));
215
+ const badConfig = run('server/cli.js', ['--url', 'http://localhost:9/', '--mode', 'load'], ws);
216
+ assert.equal(badConfig.status, 1);
217
+ assert.match(badConfig.stderr, /Unknown budget "tbt"/);
218
+
219
+ const runsOnSuite = run('bin/spectra.js', ['performance', '--runs', '3', '--url', 'http://localhost:9/'], ws);
220
+ assert.equal(runsOnSuite.status, 1);
221
+ assert.match(runsOnSuite.stderr, /--runs works with single-page profiles/);
222
+
223
+ const quickLive = run('bin/spectra.js', ['performance', '--quick', '--mode', 'live', '--url', 'http://localhost:9/'], ws);
224
+ assert.equal(quickLive.status, 1);
225
+ fs.rmSync(ws.dir, { recursive: true, force: true });
226
+ });
package/shared/compare.js CHANGED
@@ -84,6 +84,7 @@ export function formatValue(unit, v) {
84
84
  if (!finite(v)) return '–';
85
85
  switch (unit) {
86
86
  case 'ms':
87
+ if (v === 0) return '0 ms';
87
88
  if (v >= 1000) return `${(v / 1000).toFixed(2)} s`;
88
89
  if (v >= 100) return `${Math.round(v)} ms`;
89
90
  if (v >= 1) return `${v.toFixed(1)} ms`;
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The machine-readable side of Spectra: what `--json` prints, what `--out`
3
+ * writes, and what editor / CI / AI integrations consume.
4
+ *
5
+ * Pure functions, no I/O. A "result" is a small, versioned envelope that
6
+ * points at the full reports on disk instead of embedding them:
7
+ *
8
+ * kind 'profile' one or more page-load / live recordings of one page,
9
+ * reduced to a median summary plus the findings
10
+ * kind 'evaluation' the golden-path suite: one summary per journey
11
+ * kind 'compare' before vs after, judged against run-to-run noise
12
+ *
13
+ * Every summary metric is "lower is better", which is what budgets assume.
14
+ */
15
+ import { compareSides, findingKey, median } from './compare.js';
16
+
17
+ /** Bumped on breaking changes to the saved report format. */
18
+ export const REPORT_SCHEMA_VERSION = 1;
19
+ /** Bumped on breaking changes to the result envelopes below. */
20
+ export const RESULT_SCHEMA_VERSION = 1;
21
+
22
+ const finite = v => typeof v === 'number' && Number.isFinite(v);
23
+ const round = (v, d = 1) => (finite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
24
+
25
+ // ------------------------------------------------------------------ summaries
26
+
27
+ /**
28
+ * The headline numbers budgets and summaries are built from. `get` reads a
29
+ * full report; every entry is lower-is-better.
30
+ */
31
+ export const SUMMARY_METRICS = [
32
+ { key: 'tbtMs', label: 'Total blocking time', unit: 'ms', get: r => r.vitals?.tbtMs },
33
+ { key: 'lcpMs', label: 'Largest contentful paint', unit: 'ms', get: r => r.vitals?.lcpMs },
34
+ { key: 'fcpMs', label: 'First contentful paint', unit: 'ms', get: r => r.vitals?.fcpMs },
35
+ { key: 'cls', label: 'Cumulative layout shift', unit: 'score', digits: 3, get: r => r.vitals?.cls },
36
+ { key: 'inpMs', label: 'Interaction to next paint', unit: 'ms', get: r => r.vitals?.inpMs },
37
+ { key: 'ttfbMs', label: 'Time to first byte', unit: 'ms', get: r => r.vitals?.ttfbMs },
38
+ { key: 'longTaskCount', label: 'Long tasks', unit: 'count', get: r => r.vitals?.longTaskCount },
39
+ { key: 'maxLongTaskMs', label: 'Longest task', unit: 'ms', get: r => r.vitals?.maxLongTaskMs },
40
+ { key: 'scriptingMs', label: 'Scripting time', unit: 'ms', get: r => r.totals?.scriptingMs },
41
+ { key: 'forcedLayoutCount', label: 'Forced layouts', unit: 'count', get: r => r.totals?.forcedLayoutCount },
42
+ { key: 'domNodes', label: 'DOM nodes', unit: 'count', get: r => r.memory?.domNodes },
43
+ { key: 'heapUsedMb', label: 'JS heap used', unit: 'MB', get: r => r.memory?.heapUsedMb },
44
+ {
45
+ key: 'transferKb',
46
+ label: 'Transferred',
47
+ unit: 'KB',
48
+ get: r => (finite(r.network?.totalEncoded) ? r.network.totalEncoded / 1024 : null),
49
+ },
50
+ {
51
+ key: 'criticalFindings',
52
+ label: 'Critical findings',
53
+ unit: 'count',
54
+ get: r => (Array.isArray(r.findings) ? r.findings.filter(f => f.severity === 'critical').length : null),
55
+ },
56
+ {
57
+ key: 'findingCount',
58
+ label: 'Findings',
59
+ unit: 'count',
60
+ get: r => (Array.isArray(r.findings) ? r.findings.length : null),
61
+ },
62
+ ];
63
+
64
+ const metricDef = key => SUMMARY_METRICS.find(m => m.key === key);
65
+ const valueOf = (def, r) => {
66
+ const v = def.get(r);
67
+ return finite(v) ? v : null;
68
+ };
69
+
70
+ /** Headline numbers of one report; null where the recording has no value. */
71
+ export function summarize(report) {
72
+ const out = {};
73
+ for (const def of SUMMARY_METRICS) out[def.key] = round(valueOf(def, report), def.digits ?? 1);
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * Median of each headline number across runs, plus the min / max spread
79
+ * (only when there is more than one run, so the spread means something).
80
+ */
81
+ export function summarizeRuns(reports) {
82
+ const summary = { runs: reports.length };
83
+ const spread = {};
84
+ for (const def of SUMMARY_METRICS) {
85
+ const values = reports.map(r => valueOf(def, r)).filter(finite);
86
+ summary[def.key] = values.length ? round(median(values), def.digits ?? 1) : null;
87
+ if (reports.length > 1 && values.length > 1) {
88
+ spread[def.key] = {
89
+ min: round(Math.min(...values), def.digits ?? 1),
90
+ max: round(Math.max(...values), def.digits ?? 1),
91
+ };
92
+ }
93
+ }
94
+ return { summary, spread };
95
+ }
96
+
97
+ /**
98
+ * The run that best stands for the set: closest to the median on main-thread
99
+ * cost. Its findings are the ones reported, so they match the summary.
100
+ */
101
+ export function representativeRun(reports) {
102
+ if (reports.length <= 2) return reports[reports.length - 1] || null;
103
+ const keys = ['tbtMs', 'scriptingMs', 'lcpMs'];
104
+ const medians = Object.fromEntries(
105
+ keys.map(k => [k, median(reports.map(r => valueOf(metricDef(k), r)).filter(finite))]),
106
+ );
107
+ let best = null;
108
+ let bestScore = Infinity;
109
+ for (const r of reports) {
110
+ let score = 0;
111
+ for (const k of keys) {
112
+ const v = valueOf(metricDef(k), r);
113
+ const m = medians[k];
114
+ if (finite(v) && finite(m)) score += Math.abs(v - m) / Math.max(Math.abs(m), 1);
115
+ }
116
+ if (score < bestScore) {
117
+ best = r;
118
+ bestScore = score;
119
+ }
120
+ }
121
+ return best;
122
+ }
123
+
124
+ /** A finding trimmed to what a reader (or an agent) acts on. */
125
+ export function briefFinding(f) {
126
+ const top = (f.culprits || [])[0];
127
+ return {
128
+ id: f.id,
129
+ severity: f.severity,
130
+ category: f.category,
131
+ title: f.title,
132
+ detail: f.detail || '',
133
+ impactMs: round(f.impactMs || 0),
134
+ file: f.file || top?.file || null,
135
+ line: top?.line ?? null,
136
+ fix: f.fix || '',
137
+ culprits: (f.culprits || []).slice(0, 5).map(c => ({
138
+ fn: c.fn,
139
+ file: c.file || null,
140
+ line: c.line ?? null,
141
+ lineKind: c.lineKind || null,
142
+ abs: c.abs || null,
143
+ ms: round(c.ms),
144
+ note: c.note || '',
145
+ })),
146
+ };
147
+ }
148
+
149
+ // ------------------------------------------------------------------ budgets
150
+
151
+ export const BUDGET_KEYS = SUMMARY_METRICS.map(m => m.key);
152
+
153
+ /**
154
+ * Validates a `budgets` object from spectra.config.json.
155
+ * @returns {{ok: true, budgets: object} | {ok: false, error: string}}
156
+ */
157
+ export function validateBudgets(budgets) {
158
+ if (budgets == null) return { ok: true, budgets: {} };
159
+ if (typeof budgets !== 'object' || Array.isArray(budgets))
160
+ return { ok: false, error: '"budgets" must be an object such as { "tbtMs": 200 }.' };
161
+ for (const [key, limit] of Object.entries(budgets)) {
162
+ if (!BUDGET_KEYS.includes(key))
163
+ return { ok: false, error: `Unknown budget "${key}". Known budgets: ${BUDGET_KEYS.join(', ')}.` };
164
+ if (!finite(limit) || limit < 0)
165
+ return { ok: false, error: `Budget "${key}" must be a number ≥ 0 (got ${JSON.stringify(limit)}).` };
166
+ }
167
+ return { ok: true, budgets };
168
+ }
169
+
170
+ /**
171
+ * Checks budgets (upper limits) against one or more measured summaries, e.g.
172
+ * the median of a profile, or every journey of an evaluation. A metric the
173
+ * recording did not measure is reported as skipped, never as a failure.
174
+ * @param {object} budgets { tbtMs: 200, ... } (already validated)
175
+ * @param {{name: string, summary: object}[]} items
176
+ */
177
+ export function checkBudgets(budgets, items) {
178
+ const entries = Object.entries(budgets || {});
179
+ if (!entries.length) return null;
180
+ const checks = [];
181
+ for (const [key, limit] of entries) {
182
+ const def = metricDef(key);
183
+ for (const item of items) {
184
+ const value = item.summary?.[key];
185
+ checks.push({
186
+ key,
187
+ label: def.label,
188
+ unit: def.unit,
189
+ limit,
190
+ value: finite(value) ? value : null,
191
+ where: item.name,
192
+ status: !finite(value) ? 'skipped' : value <= limit ? 'pass' : 'fail',
193
+ });
194
+ }
195
+ }
196
+ const failed = checks.filter(c => c.status === 'fail');
197
+ return { ok: failed.length === 0, failed: failed.length, checks };
198
+ }
199
+
200
+ // ------------------------------------------------------------------ envelopes
201
+
202
+ const envelope = (kind, spectraVersion) => ({
203
+ schemaVersion: RESULT_SCHEMA_VERSION,
204
+ kind,
205
+ spectraVersion: spectraVersion || null,
206
+ createdAt: new Date().toISOString(),
207
+ });
208
+
209
+ /**
210
+ * @param {object} o
211
+ * @param {object[]} o.reports full reports, in run order
212
+ * @param {(id: string) => string} o.fileOf where each report is saved
213
+ */
214
+ export function buildProfileResult({ reports, fileOf, target, spectraVersion, budgets }) {
215
+ const first = reports[0];
216
+ const { summary, spread } = summarizeRuns(reports);
217
+ const rep = representativeRun(reports);
218
+ const keysPerRun = reports.map(r => new Set((r.findings || []).map(findingKey)));
219
+ const findings = (rep?.findings || []).map(f => {
220
+ const brief = briefFinding(f);
221
+ if (reports.length > 1) brief.seenIn = keysPerRun.filter(s => s.has(findingKey(f))).length;
222
+ return brief;
223
+ });
224
+ return {
225
+ ...envelope('profile', spectraVersion),
226
+ target: target || null,
227
+ url: first?.meta?.url || null,
228
+ mode: first?.meta?.mode || null,
229
+ cpuThrottle: first?.meta?.cpuThrottle || 1,
230
+ git: first?.meta?.git || null,
231
+ runs: reports.map(r => ({ id: r.id, file: fileOf ? fileOf(r.id) : null, summary: summarize(r) })),
232
+ representativeRunId: rep?.id || null,
233
+ summary,
234
+ spread,
235
+ findings,
236
+ budgets: checkBudgets(budgets, [{ name: 'median', summary }]),
237
+ };
238
+ }
239
+
240
+ /**
241
+ * @param {object} o
242
+ * @param {object} o.evaluation the object runEvaluation() returns
243
+ * @param {(id: string) => object|null} o.readReport
244
+ */
245
+ export function buildEvaluationResult({ evaluation, readReport, fileOf, file, spectraVersion, budgets }) {
246
+ const journey = j => {
247
+ const report = j.id ? readReport(j.id) : null;
248
+ return {
249
+ title: j.title,
250
+ kind: j.kind,
251
+ ok: j.ok,
252
+ error: j.error || null,
253
+ reportId: j.id || null,
254
+ reportFile: j.id && fileOf ? fileOf(j.id) : null,
255
+ summary: report ? summarize(report) : null,
256
+ findings: report ? (report.findings || []).slice(0, 5).map(briefFinding) : [],
257
+ };
258
+ };
259
+ const journeys = [journey(evaluation.baseline), ...evaluation.paths.map(journey)];
260
+ return {
261
+ ...envelope('evaluation', spectraVersion),
262
+ target: evaluation.target || null,
263
+ url: evaluation.url,
264
+ file: file || null,
265
+ discovered: evaluation.discovered,
266
+ skippedDestructive: evaluation.skippedDestructive || [],
267
+ passed: evaluation.paths.filter(p => p.ok).length,
268
+ total: evaluation.paths.length,
269
+ journeys,
270
+ budgets: checkBudgets(
271
+ budgets,
272
+ journeys.filter(j => j.summary).map(j => ({ name: j.title, summary: j.summary })),
273
+ ),
274
+ };
275
+ }
276
+
277
+ /** Before vs after; the comparison itself is shared/compare.js (COMPARE_VERSION). */
278
+ export function buildCompareResult({ before, after, spectraVersion }) {
279
+ const comparison = compareSides({ runs: before }, { runs: after });
280
+ if (!comparison) return null;
281
+ return { ...envelope('compare', spectraVersion), comparison };
282
+ }
283
+
284
+ /** The saved reports, newest first (rows from recorder.listReports()). */
285
+ export function buildReportsResult({ reports, dir, spectraVersion }) {
286
+ return { ...envelope('reports', spectraVersion), dir: dir || null, reports };
287
+ }
288
+
289
+ /** A compare counts as a regression when any key metric got worse. */
290
+ export const isRegression = result =>
291
+ ['regressed', 'mixed'].includes(result?.comparison?.verdict);
package/src/styles.css CHANGED
@@ -656,14 +656,23 @@ label.switch {
656
656
  }
657
657
  .main-tabs {
658
658
  align-items: center;
659
+ flex-wrap: wrap;
660
+ row-gap: 0.4rem;
661
+ overflow-x: visible;
659
662
  padding: 0.5rem 0.9rem;
660
663
  border-bottom: 1px solid var(--border);
661
664
  }
665
+ /* Wrap rather than scroll: a scrolled-away tab (Compare commits, on a laptop
666
+ screen) is a tab nobody finds, since overlay scrollbars stay hidden. */
662
667
  .main-tabs .tab-list {
663
668
  display: flex;
669
+ flex-wrap: wrap;
664
670
  gap: 0.2rem;
665
671
  min-width: 0;
666
- overflow-x: auto;
672
+ }
673
+ .main-tabs label.switch {
674
+ white-space: nowrap;
675
+ margin-left: auto;
667
676
  }
668
677
  .tabs button {
669
678
  background: transparent;
@@ -1188,7 +1197,8 @@ tr.detail td {
1188
1197
  }
1189
1198
  .task-cols {
1190
1199
  display: grid;
1191
- grid-template-columns: 1fr 1fr;
1200
+ /* minmax(0, …): a long file path must not push its column out of the card */
1201
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
1192
1202
  gap: 1rem;
1193
1203
  }
1194
1204
  .task-cols h4 {
@@ -1198,17 +1208,24 @@ tr.detail td {
1198
1208
  letter-spacing: 0.04em;
1199
1209
  color: var(--text-2);
1200
1210
  }
1201
- .row {
1211
+ .row,
1212
+ button.link.row {
1202
1213
  display: flex;
1203
1214
  justify-content: space-between;
1204
1215
  gap: 0.6rem;
1205
1216
  width: 100%;
1217
+ text-align: left;
1206
1218
  }
1207
1219
  .row span {
1220
+ min-width: 0;
1208
1221
  overflow: hidden;
1209
1222
  text-overflow: ellipsis;
1210
1223
  white-space: nowrap;
1211
1224
  }
1225
+ .row b {
1226
+ flex: none;
1227
+ white-space: nowrap;
1228
+ }
1212
1229
 
1213
1230
  /* ---- source view ---- */
1214
1231
  .code-scroll {