@progen-labs/spectra 0.1.3 → 0.3.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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared output plumbing for the CLI scripts. In --json mode stdout carries
3
+ * nothing but the result document, so it can be piped (`| jq`, an editor, an
4
+ * agent); every human-readable line goes to stderr instead.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { REPORTS_DIR, TOOL_ROOT } from './config.js';
9
+ import { formatValue } from '../shared/compare.js';
10
+
11
+ export const SPECTRA_VERSION = JSON.parse(
12
+ fs.readFileSync(path.join(TOOL_ROOT, 'package.json'), 'utf8'),
13
+ ).version;
14
+
15
+ /** 0 = done, 1 = could not run, 2 = ran, but a budget or a compare failed. */
16
+ export const EXIT = { OK: 0, ERROR: 1, CHECKS_FAILED: 2 };
17
+
18
+ export const reportFile = id => path.join(REPORTS_DIR, `${id}.json`);
19
+
20
+ /** Writes the result to --out and/or stdout (--json). */
21
+ export function writeResult(result, { json, out }) {
22
+ if (out) {
23
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
24
+ fs.writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`);
25
+ }
26
+ if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
27
+ }
28
+
29
+ /**
30
+ * process.exit() can cut a piped stdout short (pipes are asynchronous on
31
+ * macOS), so exit only once everything written so far has been flushed.
32
+ */
33
+ export function exitAfterFlush(code) {
34
+ process.stdout.write('', () => process.exit(code));
35
+ }
36
+
37
+ const unitOf = unit => (unit === 'KB' ? 'bytes' : unit === 'count' ? 'count' : unit);
38
+ export const fmt = (unit, v) =>
39
+ v == null ? '—' : formatValue(unitOf(unit), unit === 'KB' ? v * 1024 : v);
40
+
41
+ export function printBudgets(budgets, log) {
42
+ if (!budgets) return;
43
+ log(`\nBudgets: ${budgets.ok ? 'all within budget' : `${budgets.failed} over budget`}`);
44
+ for (const c of budgets.checks) {
45
+ const mark = c.status === 'pass' ? '✓' : c.status === 'fail' ? '✗' : '–';
46
+ const where = c.where && c.where !== 'median' ? ` (${c.where})` : '';
47
+ const value = c.status === 'skipped' ? 'not measured' : fmt(c.unit, c.value);
48
+ log(` ${mark} ${c.label.padEnd(26)} ${value.padStart(12)} limit ${fmt(c.unit, c.limit)}${where}`);
49
+ }
50
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Per-project settings: spectra.config.json in the profiled project's root
3
+ * (or the file passed with --config). Today it holds performance budgets:
4
+ *
5
+ * { "budgets": { "tbtMs": 200, "lcpMs": 2500, "cls": 0.1, "criticalFindings": 0 } }
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { FRONTEND_ROOT } from './config.js';
10
+ import { validateBudgets } from '../shared/result.js';
11
+
12
+ export const CONFIG_FILE = 'spectra.config.json';
13
+
14
+ /** @returns {{file: string|null, budgets: object}} — throws a readable Error on bad config */
15
+ export function loadProjectConfig(explicitFile) {
16
+ const file = explicitFile ? path.resolve(explicitFile) : path.join(FRONTEND_ROOT, CONFIG_FILE);
17
+ if (!fs.existsSync(file)) {
18
+ if (explicitFile) throw new Error(`config file not found: ${file}`);
19
+ return { file: null, budgets: {} };
20
+ }
21
+ let raw;
22
+ try {
23
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'));
24
+ } catch (err) {
25
+ throw new Error(`${file} is not valid JSON (${err.message}).`);
26
+ }
27
+ const checked = validateBudgets(raw?.budgets);
28
+ if (!checked.ok) throw new Error(`${path.basename(file)}: ${checked.error}`);
29
+ return { file, budgets: checked.budgets };
30
+ }
@@ -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,170 @@
1
+ // Named test-*.js (not *.test.js): the repo's jest testRegex covers tools/.
2
+ // `spectra mcp` over real stdio, on synthetic reports (no Chrome needed).
3
+ import assert from 'node:assert/strict';
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { test } from 'node:test';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { workspace } from './testing/fixtures.js';
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const BIN = path.join(ROOT, 'bin', 'spectra.js');
13
+
14
+ /** A tiny MCP client: requests, notifications, and answers to the server's own requests. */
15
+ function connect(args, { onRequest } = {}) {
16
+ const proc = spawn(process.execPath, [BIN, 'mcp', ...args], { stdio: ['pipe', 'pipe', 'pipe'] });
17
+ const waiting = new Map();
18
+ let buf = '';
19
+ let id = 0;
20
+ proc.stdout.on('data', d => {
21
+ buf += d;
22
+ let i;
23
+ while ((i = buf.indexOf('\n')) !== -1) {
24
+ const msg = JSON.parse(buf.slice(0, i));
25
+ buf = buf.slice(i + 1);
26
+ if (msg.method && msg.id != null) {
27
+ const result = onRequest?.(msg);
28
+ proc.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })}\n`);
29
+ } else if (msg.id != null) waiting.get(msg.id)?.(msg);
30
+ }
31
+ });
32
+ const write = msg => proc.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...msg })}\n`);
33
+ return {
34
+ request: (method, params) =>
35
+ new Promise(resolve => {
36
+ const my = ++id;
37
+ waiting.set(my, resolve);
38
+ write({ id: my, method, params });
39
+ }),
40
+ notify: (method, params) => write({ method, params }),
41
+ raw: line => proc.stdin.write(`${line}\n`),
42
+ close: () =>
43
+ new Promise(resolve => {
44
+ proc.on('close', resolve);
45
+ proc.stdin.end();
46
+ }),
47
+ };
48
+ }
49
+
50
+ const initialize = (client, extra = {}) =>
51
+ client.request('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1' }, ...extra });
52
+ const callTool = (client, name, args) => client.request('tools/call', { name, arguments: args });
53
+ const text = res => res.result.content[0].text;
54
+
55
+ test('mcp: handshake, tool list, version negotiation and protocol errors', async () => {
56
+ const ws = workspace();
57
+ const c = connect(['--project', ws.dir]);
58
+ const init = await initialize(c);
59
+ assert.equal(init.result.protocolVersion, '2025-06-18');
60
+ assert.equal(init.result.serverInfo.name, 'spectra');
61
+ assert.ok(init.result.capabilities.tools);
62
+ assert.match(init.result.instructions, /verify_change/);
63
+ c.notify('notifications/initialized');
64
+
65
+ const tools = (await c.request('tools/list')).result.tools;
66
+ assert.deepEqual(
67
+ tools.map(t => t.name),
68
+ ['profile_page', 'verify_change', 'compare_profiles', 'run_golden_paths', 'list_reports', 'get_report'],
69
+ );
70
+ for (const t of tools) {
71
+ assert.equal(t.inputSchema.type, 'object');
72
+ assert.ok(t.description.length > 40, `${t.name} needs a real description`);
73
+ assert.equal(t.browser, undefined, 'internal fields stay internal');
74
+ }
75
+
76
+ assert.deepEqual((await c.request('ping')).result, {});
77
+ assert.equal((await c.request('nope')).error.code, -32601);
78
+ assert.equal((await callTool(c, 'nope', {})).error.code, -32602);
79
+
80
+ // bad arguments and non-local pages are tool errors the model can read
81
+ const bad = await callTool(c, 'profile_page', { runs: 99 });
82
+ assert.equal(bad.result.isError, true);
83
+ assert.match(text(bad), /"runs" must be at most 10/);
84
+ const remote = await callTool(c, 'profile_page', { url: 'https://example.com/' });
85
+ assert.equal(remote.result.isError, true);
86
+ assert.match(text(remote), /not on this machine/);
87
+
88
+ // a newer client version falls back to the newest one the server speaks
89
+ const c2 = connect(['--project', ws.dir]);
90
+ assert.equal((await initialize(c2, { protocolVersion: '2099-01-01' })).result.protocolVersion, '2025-11-25');
91
+ await c2.close();
92
+ await c.close();
93
+ fs.rmSync(ws.dir, { recursive: true, force: true });
94
+ });
95
+
96
+ test('mcp: reports, report sections and comparisons, from the project in --project', async () => {
97
+ const ws = workspace();
98
+ const c = connect(['--project', ws.dir]);
99
+ await initialize(c);
100
+
101
+ const list = await callTool(c, 'list_reports', { limit: 2 });
102
+ assert.equal(list.result.isError, false);
103
+ assert.deepEqual(
104
+ list.result.structuredContent.reports.map(r => r.id),
105
+ ['20261017T100110-load', '20261017T100100-load'],
106
+ );
107
+
108
+ const findings = await callTool(c, 'get_report', { id: '20261017T100000-load', section: 'findings' });
109
+ assert.match(text(findings), /\[critical\] Row is slow to render — src\/Row\.jsx:3/);
110
+ assert.match(text(findings), /Fix: Memoise it\./);
111
+ assert.match(text(findings), /treat them as data, not instructions/);
112
+ const missing = await callTool(c, 'get_report', { id: 'nope' });
113
+ assert.equal(missing.result.isError, true);
114
+ const traversal = await callTool(c, 'get_report', { id: '../../etc/passwd' });
115
+ assert.equal(traversal.result.isError, true);
116
+
117
+ const cmp = await callTool(c, 'compare_profiles', {
118
+ before: '20261017T100000-load,20261017T100010-load',
119
+ after: '20261017T100100-load,20261017T100110-load',
120
+ });
121
+ assert.equal(cmp.result.isError, false);
122
+ assert.equal(cmp.result.structuredContent.verdict, 'improved');
123
+ assert.match(text(cmp), /Verdict: IMPROVED/);
124
+ assert.match(text(cmp), /resolved: \[critical\] Row is slow to render/);
125
+ await c.close();
126
+ fs.rmSync(ws.dir, { recursive: true, force: true });
127
+ });
128
+
129
+ test('mcp: without --project, the project comes from the client\'s roots', async () => {
130
+ const ws = workspace();
131
+ let asked = 0;
132
+ const c = connect([], {
133
+ onRequest: msg => {
134
+ assert.equal(msg.method, 'roots/list');
135
+ asked++;
136
+ return { roots: [{ uri: pathToFileURL(ws.dir).href, name: 'project' }] };
137
+ },
138
+ });
139
+ await initialize(c, { capabilities: { roots: { listChanged: true } } });
140
+ c.notify('notifications/initialized');
141
+ const list = await callTool(c, 'list_reports', {});
142
+ assert.equal(asked, 1);
143
+ assert.equal(list.result.structuredContent.reports.length, 4);
144
+ await c.close();
145
+ fs.rmSync(ws.dir, { recursive: true, force: true });
146
+ });
147
+
148
+ test('mcp: conforms to the official MCP SDK client', async t => {
149
+ let Client, StdioClientTransport;
150
+ try {
151
+ ({ Client } = await import('@modelcontextprotocol/sdk/client/index.js'));
152
+ ({ StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js'));
153
+ } catch {
154
+ t.skip('@modelcontextprotocol/sdk is not installed (dev dependency)');
155
+ return;
156
+ }
157
+ const ws = workspace();
158
+ const client = new Client({ name: 'sdk-conformance', version: '1.0.0' });
159
+ await client.connect(new StdioClientTransport({ command: process.execPath, args: [BIN, 'mcp', '--project', ws.dir], stderr: 'ignore' }));
160
+ assert.equal(client.getServerVersion().name, 'spectra');
161
+ const { tools } = await client.listTools(); // the SDK validates every tool definition
162
+ assert.equal(tools.length, 6);
163
+ const res = await client.callTool({ name: 'get_report', arguments: { id: '20261017T100110-load', section: 'overview' } });
164
+ assert.equal(res.isError, false);
165
+ assert.match(res.content[0].text, /Report 20261017T100110-load/);
166
+ const cmp = await client.callTool({ name: 'compare_profiles', arguments: { before: '20261017T100000-load', after: '20261017T100100-load' } });
167
+ assert.equal(cmp.structuredContent.verdict, 'improved');
168
+ await client.close();
169
+ fs.rmSync(ws.dir, { recursive: true, force: true });
170
+ });
@@ -0,0 +1,183 @@
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 path from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ BUDGET_KEYS,
10
+ RESULT_SCHEMA_VERSION,
11
+ SUMMARY_METRICS,
12
+ briefFinding,
13
+ buildCompareResult,
14
+ buildEvaluationResult,
15
+ buildProfileResult,
16
+ checkBudgets,
17
+ isRegression,
18
+ representativeRun,
19
+ summarize,
20
+ summarizeRuns,
21
+ validateBudgets,
22
+ } from '../shared/result.js';
23
+ import { finding, report, workspace } from './testing/fixtures.js';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const readSchema = name => JSON.parse(fs.readFileSync(path.join(ROOT, 'schema', name), 'utf8'));
27
+
28
+ test('summarize reads the headline numbers, rounded, with null where unmeasured', () => {
29
+ const s = summarize(report('a', { tbt: 212.96 }));
30
+ assert.equal(s.tbtMs, 213);
31
+ assert.equal(s.cls, 0.002);
32
+ assert.equal(s.inpMs, null);
33
+ assert.equal(s.transferKb, 2048);
34
+ assert.equal(s.criticalFindings, 1);
35
+ assert.deepEqual(Object.keys(s), SUMMARY_METRICS.map(m => m.key));
36
+ });
37
+
38
+ test('several runs reduce to medians with a min / max spread; the typical run supplies the findings', () => {
39
+ const runs = [report('r1', { scripting: 900 }), report('r2', { scripting: 1000 }), report('r3', { scripting: 2000 })];
40
+ const { summary, spread } = summarizeRuns(runs);
41
+ assert.equal(summary.runs, 3);
42
+ assert.equal(summary.scriptingMs, 1000);
43
+ assert.deepEqual(spread.scriptingMs, { min: 900, max: 2000 });
44
+ assert.equal(representativeRun(runs).id, 'r2');
45
+ // one run: no spread to speak of
46
+ assert.deepEqual(summarizeRuns([runs[0]]).spread, {});
47
+ });
48
+
49
+ test('briefFinding keeps what a reader acts on', () => {
50
+ const b = briefFinding(finding());
51
+ assert.equal(b.impactMs, 1442.9);
52
+ assert.equal(b.line, 3);
53
+ assert.equal(b.culprits[0].abs, '/p/src/Row.jsx');
54
+ assert.equal(b.fix, 'Memoise it.');
55
+ });
56
+
57
+ test('budgets: unknown keys and bad limits are rejected, unmeasured metrics are skipped', () => {
58
+ assert.equal(validateBudgets({ tbt: 200 }).ok, false);
59
+ assert.match(validateBudgets({ tbt: 200 }).error, /Unknown budget "tbt"/);
60
+ assert.equal(validateBudgets({ tbtMs: -1 }).ok, false);
61
+ assert.equal(validateBudgets(undefined).ok, true);
62
+ const res = checkBudgets({ tbtMs: 200, inpMs: 100, criticalFindings: 0 }, [{ name: 'median', summary: summarize(report('a', { tbt: 250 })) }]);
63
+ assert.equal(res.ok, false);
64
+ assert.equal(res.failed, 2);
65
+ assert.deepEqual(res.checks.map(c => c.status), ['fail', 'skipped', 'fail']);
66
+ assert.equal(checkBudgets({}, []), null);
67
+ });
68
+
69
+ test('profile result: versioned envelope, per-run summaries, findings marked with how many runs saw them', () => {
70
+ const runs = [report('r1'), report('r2', { findings: [] }), report('r3')];
71
+ const result = buildProfileResult({ reports: runs, fileOf: id => `/reports/${id}.json`, target: 'demo', spectraVersion: '9.9.9', budgets: { tbtMs: 200 } });
72
+ assert.equal(result.schemaVersion, RESULT_SCHEMA_VERSION);
73
+ assert.equal(result.kind, 'profile');
74
+ assert.equal(result.runs[1].file, '/reports/r2.json');
75
+ assert.equal(result.findings[0].seenIn, 2);
76
+ assert.equal(result.budgets.ok, true);
77
+ });
78
+
79
+ test('evaluation result: one summary per journey, budgets name the journey that broke them', () => {
80
+ const reports = { base: report('base'), p1: report('p1', { tbt: 400 }) };
81
+ const evaluation = {
82
+ url: 'http://localhost:5188/',
83
+ target: 'demo',
84
+ discovered: 3,
85
+ skippedDestructive: [],
86
+ baseline: { id: 'base', title: 'Initial page load', kind: 'load', ok: true },
87
+ paths: [{ id: 'p1', title: 'Click "Long task"', kind: 'interaction', ok: true }],
88
+ };
89
+ const result = buildEvaluationResult({ evaluation, readReport: id => reports[id], budgets: { tbtMs: 200 } });
90
+ assert.equal(result.journeys.length, 2);
91
+ assert.equal(result.passed, 1);
92
+ const failed = result.budgets.checks.filter(c => c.status === 'fail');
93
+ assert.deepEqual(failed.map(c => c.where), ['Click "Long task"']);
94
+ });
95
+
96
+ test('compare result wraps the shared comparison and flags regressions', () => {
97
+ const slow = [report('b1', { scripting: 1500 }), report('b2', { scripting: 1520 })];
98
+ const fast = [report('a1', { scripting: 90, findings: [] }), report('a2', { scripting: 95, findings: [] })];
99
+ const better = buildCompareResult({ before: slow, after: fast });
100
+ assert.equal(better.kind, 'compare');
101
+ assert.equal(better.comparison.verdict, 'improved');
102
+ assert.equal(better.comparison.findings.resolved.length, 1);
103
+ assert.equal(isRegression(better), false);
104
+ assert.equal(isRegression(buildCompareResult({ before: fast, after: slow })), true);
105
+ });
106
+
107
+ test('the published schemas list exactly the metrics and budgets the code produces', () => {
108
+ const result = readSchema('result.schema.json');
109
+ assert.deepEqual(Object.keys(result.$defs.summary.properties), SUMMARY_METRICS.map(m => m.key));
110
+ assert.equal(result.properties.schemaVersion.const, RESULT_SCHEMA_VERSION);
111
+ const config = readSchema('config.schema.json');
112
+ assert.deepEqual(Object.keys(config.properties.budgets.properties), BUDGET_KEYS);
113
+ // every key the schema requires is present in what the builders return
114
+ const profile = buildProfileResult({ reports: [report('r1')], budgets: {} });
115
+ for (const key of [...result.required, ...result.$defs.profile.required]) assert.ok(key in profile, `profile result lacks ${key}`);
116
+ const finding0 = profile.findings[0];
117
+ for (const key of result.$defs.finding.required) assert.ok(key in finding0, `finding lacks ${key}`);
118
+ });
119
+
120
+ // ------------------------------------------------------------------ CLI (no Chrome needed)
121
+
122
+ const run = (script, args, ws) =>
123
+ spawnSync(process.execPath, [path.join(ROOT, script), ...args], {
124
+ cwd: ws.dir,
125
+ encoding: 'utf8',
126
+ env: { ...process.env, PERF_FRONTEND_DIR: ws.dir, PERF_REPORTS_DIR: ws.reports },
127
+ });
128
+
129
+ test('cli: compare by ids prints a verdict, emits JSON, and exits 2 on a regression when asked', () => {
130
+ const ws = workspace();
131
+ const before = '20261017T100000-load,20261017T100010-load';
132
+ const after = '20261017T100100-load,20261017T100110-load';
133
+ const ok = run('bin/spectra.js', ['compare', before, after, '--json'], ws);
134
+ assert.equal(ok.status, 0, ok.stderr);
135
+ const result = JSON.parse(ok.stdout);
136
+ assert.equal(result.comparison.verdict, 'improved');
137
+ assert.match(ok.stderr, /Spectra compare — IMPROVED/);
138
+
139
+ const worse = run('bin/spectra.js', ['compare', after, before, '--fail-on-regression'], ws);
140
+ assert.equal(worse.status, 2);
141
+ assert.match(worse.stdout, /REGRESSED/);
142
+ fs.rmSync(ws.dir, { recursive: true, force: true });
143
+ });
144
+
145
+ test('cli: a saved profile result works as a compare side, from any directory', () => {
146
+ const ws = workspace();
147
+ const profile = buildProfileResult({
148
+ reports: ['20261017T100000-load', '20261017T100010-load'].map(id => JSON.parse(fs.readFileSync(path.join(ws.reports, `${id}.json`), 'utf8'))),
149
+ fileOf: id => path.join(ws.reports, `${id}.json`),
150
+ });
151
+ fs.writeFileSync(path.join(ws.dir, 'before.json'), JSON.stringify(profile));
152
+ const res = run('bin/spectra.js', ['compare', 'before.json', '20261017T100100-load', '--out', 'cmp.json'], ws);
153
+ assert.equal(res.status, 0, res.stderr);
154
+ const written = JSON.parse(fs.readFileSync(path.join(ws.dir, 'cmp.json'), 'utf8'));
155
+ assert.equal(written.comparison.before.runs.length, 2);
156
+ fs.rmSync(ws.dir, { recursive: true, force: true });
157
+ });
158
+
159
+ test('cli: reports lists newest first; bad input fails fast with exit 1 and no browser', () => {
160
+ const ws = workspace();
161
+ const list = run('bin/spectra.js', ['reports', '--json'], ws);
162
+ assert.equal(list.status, 0, list.stderr);
163
+ const parsed = JSON.parse(list.stdout);
164
+ assert.equal(parsed.kind, 'reports');
165
+ assert.equal(parsed.reports[0].id, '20261017T100110-load');
166
+
167
+ const missing = run('bin/spectra.js', ['compare', 'nope', '20261017T100100-load'], ws);
168
+ assert.equal(missing.status, 1);
169
+ assert.match(missing.stderr, /no report "nope"/);
170
+
171
+ fs.writeFileSync(path.join(ws.dir, 'spectra.config.json'), JSON.stringify({ budgets: { tbt: 200 } }));
172
+ const badConfig = run('server/cli.js', ['--url', 'http://localhost:9/', '--mode', 'load'], ws);
173
+ assert.equal(badConfig.status, 1);
174
+ assert.match(badConfig.stderr, /Unknown budget "tbt"/);
175
+
176
+ const runsOnSuite = run('bin/spectra.js', ['performance', '--runs', '3', '--url', 'http://localhost:9/'], ws);
177
+ assert.equal(runsOnSuite.status, 1);
178
+ assert.match(runsOnSuite.stderr, /--runs works with single-page profiles/);
179
+
180
+ const quickLive = run('bin/spectra.js', ['performance', '--quick', '--mode', 'live', '--url', 'http://localhost:9/'], ws);
181
+ assert.equal(quickLive.status, 1);
182
+ fs.rmSync(ws.dir, { recursive: true, force: true });
183
+ });
@@ -0,0 +1,49 @@
1
+ // Synthetic reports and throwaway project folders for the tests. Not a test
2
+ // file itself (only server/**/test-*.js run), so it can be imported freely.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { REPORT_SCHEMA_VERSION } from '../../shared/result.js';
7
+
8
+ export const finding = (over = {}) => ({
9
+ id: 'slow-component-Row-src/Row.jsx',
10
+ severity: 'critical',
11
+ category: 'React',
12
+ title: 'Row is slow to render',
13
+ detail: 'src/Row.jsx:3.',
14
+ impactMs: 1442.94,
15
+ file: 'src/Row.jsx',
16
+ fix: 'Memoise it.',
17
+ culprits: [{ fn: 'Row', file: 'src/Row.jsx', line: 3, lineKind: 'exact', abs: '/p/src/Row.jsx', ms: 1442.94, note: 'slow render' }],
18
+ ...over,
19
+ });
20
+
21
+ /** A report with just enough in it for summaries, budgets and compare. */
22
+ export function report(id, { tbt = 0, lcp = 100, scripting = 1000, findings = [finding()] } = {}) {
23
+ return {
24
+ schemaVersion: REPORT_SCHEMA_VERSION,
25
+ id,
26
+ createdAt: '2026-10-17T10:00:00.000Z',
27
+ meta: { url: 'http://localhost:5188/', title: 'Demo', mode: 'load', durationMs: 4000, cpuThrottle: 1, attribution: 'sourcemap', git: null, label: '' },
28
+ vitals: { tbtMs: tbt, lcpMs: lcp, fcpMs: lcp, cls: 0.0015, inpMs: null, ttfbMs: 2.4, longTaskCount: 0, maxLongTaskMs: 0 },
29
+ 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 },
30
+ memory: { domNodes: 800, listeners: 20, heapUsedMb: 9.4, heapGrowthMb: 0 },
31
+ network: { count: 10, totalEncoded: 2048 * 1024 },
32
+ react: { detected: true, hasTimings: true, version: '18.3.1', components: [], commits: { count: 60, totalMs: 100, maxMs: 10 } },
33
+ files: [],
34
+ findings,
35
+ };
36
+ }
37
+
38
+ /** A project folder with four saved reports: two slow (before), two fast (after). */
39
+ export function workspace() {
40
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-cli-'));
41
+ const reports = path.join(dir, '.spectra', 'reports');
42
+ fs.mkdirSync(reports, { recursive: true });
43
+ const save = r => fs.writeFileSync(path.join(reports, `${r.id}.json`), JSON.stringify(r));
44
+ save(report('20261017T100000-load', { scripting: 1500 }));
45
+ save(report('20261017T100010-load', { scripting: 1510 }));
46
+ save(report('20261017T100100-load', { scripting: 90, findings: [] }));
47
+ save(report('20261017T100110-load', { scripting: 92, findings: [] }));
48
+ return { dir, reports };
49
+ }
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`;