@progen-labs/spectra 0.1.2 → 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.
@@ -241,13 +241,17 @@ export function buildFindings(report) {
241
241
  const slowFix =
242
242
  fw === 'vue'
243
243
  ? 'Move expensive work into a computed property, use v-memo/v-once for static subtrees, and virtualise long lists.'
244
- : fw === 'react' || fw === 'next'
245
- ? 'Move expensive calculations into useMemo, virtualise long lists, and avoid deriving large data structures inside render.'
246
- : 'Memoize expensive work, virtualise long lists, and avoid building large data structures during render.';
244
+ : fw === 'angular'
245
+ ? 'Avoid heavy work in template expressions and getters — move it to a memoized value or pure pipe — and use ChangeDetectionStrategy.OnPush.'
246
+ : fw === 'react' || fw === 'next'
247
+ ? 'Move expensive calculations into useMemo, virtualise long lists, and avoid deriving large data structures inside render.'
248
+ : 'Memoize expensive work, virtualise long lists, and avoid building large data structures during render.';
247
249
  const frequentFix =
248
250
  fw === 'vue'
249
251
  ? 'Find the reactive dependency that changes this often (a ref/store/timer), narrow it, and use v-memo to skip unchanged subtrees.'
250
- : 'Find what updates this often (a timer, a store subscription, a scroll handler) and throttle it or narrow the selector/subscription.';
252
+ : fw === 'angular'
253
+ ? 'Reduce change-detection triggers: use OnPush, run timers/listeners with NgZone.runOutsideAngular, and avoid method/getter calls in templates.'
254
+ : 'Find what updates this often (a timer, a store subscription, a scroll handler) and throttle it or narrow the selector/subscription.';
251
255
  if (react.detected && react.hasTimings) {
252
256
  react.components
253
257
  .filter(c => c.wasted >= 5 && c.wasted / c.renders >= 0.3)
package/server/args.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Tiny argv parser shared by the CLI scripts: `--name value` options, bare
3
+ * `--flag`s, and positionals. `values` lists the options that take a value, so
4
+ * their value is never mistaken for a positional.
5
+ */
6
+ export function parseArgs(argv, { values = [] } = {}) {
7
+ const positionals = [];
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const a = argv[i];
10
+ if (a.startsWith('--')) {
11
+ if (values.includes(a.slice(2))) i++;
12
+ } else positionals.push(a);
13
+ }
14
+ const opt = (name, fallback) => {
15
+ const i = argv.indexOf(`--${name}`);
16
+ return i === -1 ? fallback : (argv[i + 1] ?? true);
17
+ };
18
+ const flag = name => argv.includes(`--${name}`);
19
+ return { opt, flag, positionals };
20
+ }
21
+
22
+ /** A positive integer option, or an Error naming the option. */
23
+ export function intOption(raw, name, { min = 1, max = Infinity, fallback } = {}) {
24
+ if (raw === undefined) return fallback;
25
+ const n = Number(raw);
26
+ if (!Number.isInteger(n) || n < min || n > max)
27
+ throw new Error(`--${name} must be a whole number from ${min} to ${max} (got ${raw === true ? 'nothing' : raw}).`);
28
+ return n;
29
+ }
package/server/cli.js CHANGED
@@ -1,94 +1,170 @@
1
1
  /**
2
- * Headless / scripted profiling:
2
+ * Single-page profiling: one or more recordings of one page.
3
3
  *
4
+ * spectra performance --quick # one page-load profile
5
+ * spectra performance --quick --runs 5 --json # median of 5, as JSON
4
6
  * npm run profile -- --url http://localhost:3000/ --mode load
5
7
  * npm run profile -- --url http://localhost:5188/ --duration 8 --throttle 4
6
8
  * npm run profile -- --url ... --scenario ./scenario.mjs # export default async page => {…}
7
9
  *
8
- * The report is saved in reports/ and can be opened in the UI (npm run dev).
10
+ * Every run is saved in reports/ and can be opened in the UI (npm run dev).
11
+ * Several runs are reduced to their median, so one noisy recording can't
12
+ * decide a number. Budgets from spectra.config.json are checked against it.
9
13
  */
10
- import path from 'node:path';
11
14
  import { pathToFileURL } from 'node:url';
15
+ import path from 'node:path';
12
16
  import { PerfSession, readReport } from './recorder.js';
13
- import { REPORTS_DIR } from './config.js';
17
+ import { REPORTS_DIR, TARGET_NAME } from './config.js';
18
+ import { intOption, parseArgs } from './args.js';
19
+ import { loadProjectConfig } from './project-config.js';
20
+ import { EXIT, SPECTRA_VERSION, exitAfterFlush, fmt, printBudgets, reportFile, writeResult } from './output.js';
21
+ import { buildProfileResult } from '../shared/result.js';
14
22
 
15
- const args = process.argv.slice(2);
16
- const opt = (name, fallback) => {
17
- const i = args.indexOf(`--${name}`);
18
- return i === -1 ? fallback : (args[i + 1] ?? true);
19
- };
20
- const flag = name => args.includes(`--${name}`);
23
+ const { opt, flag } = parseArgs(process.argv.slice(2));
21
24
 
22
25
  const url = opt('url');
23
26
  if (!url || flag('help')) {
24
- console.log(
25
- 'usage: npm run profile -- --url <page> [--mode live|load] [--duration seconds] [--throttle 4] [--scenario file.mjs] [--headed] [--save-trace] [--sign-in "http://host/login/?redirect={url}"]',
27
+ console.error(
28
+ 'usage: npm run profile -- --url <page> [--mode live|load] [--runs n] [--duration seconds] [--throttle 4] [--scenario file.mjs] [--json] [--out file.json] [--config spectra.config.json] [--headed] [--save-trace] [--sign-in "http://host/login/?redirect={url}"]',
26
29
  );
27
- process.exit(url ? 0 : 1);
30
+ process.exit(url ? EXIT.OK : EXIT.ERROR);
28
31
  }
29
- const mode = opt('mode', 'live');
30
- const duration = Number(opt('duration', 8)) * 1000;
32
+
33
+ const json = flag('json');
34
+ const out = opt('out');
35
+ // In --json mode stdout carries only the result; people still see progress.
36
+ const log = json ? console.error : console.log;
31
37
  const sleep = ms => new Promise(r => setTimeout(r, ms));
32
38
 
33
- const session = new PerfSession();
39
+ let mode, runs, duration, cpuThrottle, config, scenario;
34
40
  try {
35
- await session.connect({
36
- mode: 'launch',
37
- url,
38
- headless: !flag('headed'),
39
- signIn: opt('sign-in'),
40
- });
41
- await sleep(1500);
42
- await session.reload(); // make sure the probe was installed before the app loaded
43
- await sleep(1000);
44
- await session.start({
45
- mode,
46
- cpuThrottle: Number(opt('throttle', 1)),
47
- saveTrace: flag('save-trace'),
41
+ mode = opt('mode', 'live');
42
+ if (!['live', 'load'].includes(mode)) throw new Error(`--mode must be live or load (got ${mode}).`);
43
+ runs = intOption(opt('runs'), 'runs', { max: 25, fallback: 1 });
44
+ duration = Number(opt('duration', 8)) * 1000;
45
+ cpuThrottle = Number(opt('throttle', 1));
46
+ config = loadProjectConfig(opt('config'));
47
+ const scenarioFile = opt('scenario');
48
+ if (scenarioFile) scenario = (await import(pathToFileURL(path.resolve(scenarioFile)).href)).default;
49
+ } catch (err) {
50
+ console.error(`spectra: ${err.message}`);
51
+ process.exit(EXIT.ERROR);
52
+ }
53
+
54
+ /** A page-load recording stops itself shortly after the load event. */
55
+ function recordLoad(session, startOpts) {
56
+ return new Promise((resolve, reject) => {
57
+ const onReport = r => {
58
+ cleanup();
59
+ resolve(readReport(r.id));
60
+ };
61
+ const onState = s => {
62
+ if (s.error) {
63
+ cleanup();
64
+ reject(new Error(s.error));
65
+ }
66
+ };
67
+ const cleanup = () => {
68
+ session.off('report', onReport);
69
+ session.off('state', onState);
70
+ };
71
+ session.on('report', onReport);
72
+ session.on('state', onState);
73
+ session.start(startOpts).catch(err => {
74
+ cleanup();
75
+ reject(err);
76
+ });
48
77
  });
49
- let report;
50
- if (mode === 'live') {
51
- const scenario = opt('scenario');
52
- if (scenario) {
53
- const mod = await import(pathToFileURL(path.resolve(scenario)).href);
54
- await mod.default(session.page);
55
- } else await sleep(duration);
56
- report = await session.stop();
78
+ }
79
+
80
+ async function recordOnce(session, index) {
81
+ const startOpts = { mode, cpuThrottle, saveTrace: flag('save-trace') };
82
+ if (mode === 'load') return recordLoad(session, startOpts);
83
+ // live: every run starts from the same freshly loaded page
84
+ if (index > 0) {
85
+ await session.navigate(url, opt('sign-in'));
86
+ await sleep(800);
87
+ }
88
+ await session.start(startOpts);
89
+ if (scenario) await scenario(session.page);
90
+ else await sleep(duration);
91
+ return session.stop();
92
+ }
93
+
94
+ const lineRef = c => (c.line ? `:${c.lineKind === 'located' ? '≈' : ''}${c.line}` : '');
95
+
96
+ function printProfile(result, reports) {
97
+ const rep = reports.find(r => r.id === result.representativeRunId) || reports[0];
98
+ const s = result.summary;
99
+ if (reports.length === 1) {
100
+ log(`\nReport ${rep.id} (${rep.meta.attribution}, ${rep.meta.sampleCount} samples)`);
101
+ log(
102
+ `TBT ${fmt('ms', s.tbtMs)} · long tasks ${s.longTaskCount ?? '—'} · CLS ${s.cls ?? '—'} · FCP ${fmt('ms', s.fcpMs)} · LCP ${fmt('ms', s.lcpMs)} · forced layouts ${s.forcedLayoutCount ?? '—'}`,
103
+ );
57
104
  } else {
58
- // Page-load recordings stop themselves shortly after the load event.
59
- const id = await new Promise((resolve, reject) => {
60
- session.once('report', r => resolve(r.id));
61
- session.on('state', s => s.error && reject(new Error(s.error)));
62
- });
63
- report = readReport(id);
105
+ const range = (key, unit) => {
106
+ const sp = result.spread[key];
107
+ return sp && sp.min !== sp.max ? ` (${fmt(unit, sp.min)}–${fmt(unit, sp.max)})` : '';
108
+ };
109
+ log(`\n${reports.length} runs — medians:`);
110
+ log(
111
+ `TBT ${fmt('ms', s.tbtMs)}${range('tbtMs', 'ms')} · LCP ${fmt('ms', s.lcpMs)}${range('lcpMs', 'ms')} · FCP ${fmt('ms', s.fcpMs)}${range('fcpMs', 'ms')} · CLS ${s.cls ?? '—'} · long tasks ${s.longTaskCount ?? '—'} · forced layouts ${s.forcedLayoutCount ?? '—'}`,
112
+ );
113
+ result.runs.forEach((r, i) =>
114
+ log(` run ${i + 1} ${r.id} TBT ${fmt('ms', r.summary.tbtMs)} · LCP ${fmt('ms', r.summary.lcpMs)} · scripting ${fmt('ms', r.summary.scriptingMs)}`),
115
+ );
64
116
  }
65
- console.log(
66
- `\nReport ${report.id} (${report.meta.attribution}, ${report.meta.sampleCount} samples)`,
117
+ log(
118
+ reports.length > 1
119
+ ? `\nCulprits (from ${rep.id}, the most typical run):`
120
+ : '\nCulprits:',
67
121
  );
68
- console.log(
69
- `TBT ${report.vitals.tbtMs}ms · long tasks ${report.vitals.longTaskCount} · CLS ${report.vitals.cls} · FCP ${report.vitals.fcpMs}ms · LCP ${report.vitals.lcpMs}ms · forced layouts ${report.totals.forcedLayoutCount}`,
70
- );
71
- console.log('\nCulprits:');
72
- for (const f of report.findings.slice(0, 15)) {
73
- console.log(` [${f.severity}] (${f.category}) ${f.title}`);
74
- for (const c of f.culprits) {
75
- const line = c.line
76
- ? `:${c.lineKind === 'located' ? '≈' : ''}${c.line}`
77
- : '';
78
- console.log(
79
- ` ↳ ${c.fn}() ${c.file || 'unknown'}${line}${c.ms ? ` ${c.ms}ms` : ''}${c.note ? ` (${c.note})` : ''}`,
80
- );
81
- }
122
+ for (const f of result.findings.slice(0, 15)) {
123
+ const seen = f.seenIn != null ? ` [seen in ${f.seenIn}/${reports.length} runs]` : '';
124
+ log(` [${f.severity}] (${f.category}) ${f.title}${seen}`);
125
+ for (const c of f.culprits)
126
+ log(` ↳ ${c.fn}() ${c.file || 'unknown'}${lineRef(c)}${c.ms ? ` ${c.ms}ms` : ''}${c.note ? ` (${c.note})` : ''}`);
127
+ }
128
+ }
129
+
130
+ const session = new PerfSession();
131
+ const reports = [];
132
+ let code = EXIT.OK;
133
+ try {
134
+ await session.connect({ mode: 'launch', url, headless: !flag('headed'), signIn: opt('sign-in') });
135
+ await sleep(1500);
136
+ await session.reload(); // make sure the probe was installed before the app loaded
137
+ await sleep(1000);
138
+ for (let i = 0; i < runs; i++) {
139
+ if (runs > 1) console.error(`spectra: run ${i + 1}/${runs} …`);
140
+ reports.push(await recordOnce(session, i));
141
+ if (i < runs - 1) await sleep(500);
82
142
  }
143
+ } catch (err) {
144
+ console.error(`spectra: ${err.message}`);
145
+ code = EXIT.ERROR;
146
+ } finally {
147
+ await session.disconnect();
148
+ }
149
+
150
+ if (code === EXIT.OK) {
151
+ const result = buildProfileResult({
152
+ reports,
153
+ fileOf: reportFile,
154
+ target: TARGET_NAME,
155
+ spectraVersion: SPECTRA_VERSION,
156
+ budgets: config.budgets,
157
+ });
158
+ printProfile(result, reports);
159
+ printBudgets(result.budgets, log);
160
+ if (result.budgets && !result.budgets.ok) code = EXIT.CHECKS_FAILED;
161
+ writeResult(result, { json, out });
162
+
83
163
  // When launched by the installed CLI the bin sets PERF_REPORTS_DIR; in a dev
84
164
  // checkout it is unset. Tailor the "how to view it" hint to each.
85
165
  const installed = !!process.env.PERF_REPORTS_DIR;
86
- console.log(
87
- `\nSaved ${path.join(REPORTS_DIR, report.id)}.json — view it with: ${
88
- installed ? 'spectra dashboard' : 'npm run dev'
89
- }`,
90
- );
91
- } finally {
92
- await session.disconnect();
166
+ const saved = reports.length === 1 ? `${reportFile(reports[0].id)}` : `${reports.length} reports in ${REPORTS_DIR}`;
167
+ log(`\nSaved ${saved} — view with: ${installed ? 'spectra dashboard' : 'npm run dev'}`);
168
+ if (out) log(`Result written to ${path.resolve(out)}`);
93
169
  }
94
- process.exit(0);
170
+ exitAfterFlush(code);
@@ -0,0 +1,126 @@
1
+ /**
2
+ * spectra compare <before> <after> [--json] [--out file.json] [--fail-on-regression]
3
+ *
4
+ * Each side is any of:
5
+ * - a report id from .spectra/reports 20261017T101500-load
6
+ * - a report file ./perf_3fa516c_load.json
7
+ * - a result file from `spectra performance --out` (all of its runs)
8
+ * - several of the above joined by commas id1,id2,id3
9
+ *
10
+ * Several runs per side are reduced to their median and a change only counts
11
+ * when it is bigger than the noise between runs (shared/compare.js).
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { readReport } from './recorder.js';
16
+ import { REPORTS_DIR } from './config.js';
17
+ import { parseArgs } from './args.js';
18
+ import { EXIT, SPECTRA_VERSION, exitAfterFlush, writeResult } from './output.js';
19
+ import { buildCompareResult, isRegression } from '../shared/result.js';
20
+ import { validateReport } from '../shared/snapshot.js';
21
+ import { formatPct, formatValue } from '../shared/compare.js';
22
+
23
+ const { opt, flag, positionals } = parseArgs(process.argv.slice(2), { values: ['out'] });
24
+ const json = flag('json');
25
+ const out = opt('out');
26
+ const log = json ? console.error : console.log;
27
+
28
+ if (flag('help') || positionals.length !== 2) {
29
+ console.error('usage: spectra compare <before> <after> [--json] [--out file.json] [--fail-on-regression]');
30
+ console.error(' each side: a report id, a report file, a `spectra performance --out` result file, or id1,id2,…');
31
+ process.exit(flag('help') ? EXIT.OK : EXIT.ERROR);
32
+ }
33
+
34
+ const readJson = file => {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
37
+ } catch (err) {
38
+ throw new Error(`${file} is not valid JSON (${err.message}).`);
39
+ }
40
+ };
41
+
42
+ /** The runs a result file points at: its recorded path, the reports dir, or next to the result. */
43
+ function loadRun(run, dir) {
44
+ const candidates = [run.file, path.join(REPORTS_DIR, `${run.id}.json`), path.join(dir, `${run.id}.json`)];
45
+ const found = candidates.find(f => f && fs.existsSync(f));
46
+ if (!found) throw new Error(`report ${run.id} (listed in the result file) was not found; it was saved to ${run.file}.`);
47
+ return readJson(found);
48
+ }
49
+
50
+ function loadToken(token) {
51
+ const file = path.resolve(token);
52
+ if (fs.existsSync(file) || token.endsWith('.json')) {
53
+ if (!fs.existsSync(file)) throw new Error(`no such file: ${file}`);
54
+ const obj = readJson(file);
55
+ if (obj?.schemaVersion && obj.kind === 'profile') return obj.runs.map(run => loadRun(run, path.dirname(file)));
56
+ if (obj?.kind === 'evaluation')
57
+ throw new Error(
58
+ `${token} is a golden-path evaluation. Compare single-page profiles instead: run "spectra performance --quick --runs 5 --out before.json" (and again for after), or compare two journey reports by id.`,
59
+ );
60
+ if (obj?.kind) throw new Error(`${token} is a "${obj.kind}" result, not a profile.`);
61
+ const valid = validateReport(obj);
62
+ if (!valid.ok) throw new Error(`${token}: ${valid.reason}`);
63
+ return [obj];
64
+ }
65
+ const report = readReport(token);
66
+ if (!report) throw new Error(`no report "${token}" in ${REPORTS_DIR}. List them with: spectra reports`);
67
+ return [report];
68
+ }
69
+
70
+ const loadSide = arg =>
71
+ arg
72
+ .split(',')
73
+ .map(t => t.trim())
74
+ .filter(Boolean)
75
+ .flatMap(loadToken);
76
+
77
+ let result;
78
+ try {
79
+ const before = loadSide(positionals[0]);
80
+ const after = loadSide(positionals[1]);
81
+ result = buildCompareResult({ before, after, spectraVersion: SPECTRA_VERSION });
82
+ if (!result) throw new Error('nothing to compare.');
83
+ } catch (err) {
84
+ console.error(`spectra: ${err.message}`);
85
+ process.exit(EXIT.ERROR);
86
+ }
87
+
88
+ const VERDICT = {
89
+ improved: 'IMPROVED',
90
+ regressed: 'REGRESSED',
91
+ mixed: 'MIXED (some better, some worse)',
92
+ neutral: 'NO SIGNIFICANT CHANGE',
93
+ };
94
+ const sideLine = side => {
95
+ const commit = side.git?.shortHash || side.runs.find(r => r.commit)?.commit;
96
+ return `${side.runs.length} run${side.runs.length === 1 ? '' : 's'}${commit ? ` · commit ${commit}` : ''}${side.label ? ` · ${side.label}` : ''}`;
97
+ };
98
+
99
+ const c = result.comparison;
100
+ log(`\nSpectra compare — ${VERDICT[c.verdict] || c.verdict} (confidence: ${c.confidence})`);
101
+ log(`Before: ${sideLine(c.before)}`);
102
+ log(`After: ${sideLine(c.after)}`);
103
+ if (c.headlines.length) {
104
+ log('');
105
+ for (const h of c.headlines) log(` ${h.kind === 'improved' ? '✓' : '✗'} ${h.text}`);
106
+ }
107
+ log(`\n${'Key metric'.padEnd(28)}${'Before'.padStart(11)}${'After'.padStart(11)}${'Change'.padStart(9)} Verdict`);
108
+ for (const m of c.metrics.filter(x => x.key)) {
109
+ log(
110
+ `${m.label.padEnd(28)}${formatValue(m.unit, m.before.value).padStart(11)}${formatValue(m.unit, m.after.value).padStart(11)}${(m.verdict === 'n/a' ? '' : formatPct(m.pct)).padStart(9)} ${m.verdict}`,
111
+ );
112
+ }
113
+ log(`(${c.counts.improved} improved · ${c.counts.degraded} worse · ${c.counts.same} within noise across all ${c.metrics.length} metrics)`);
114
+ const { added, resolved, persisting } = c.findings;
115
+ log(`\nFindings: ${resolved.length} resolved · ${added.length} new · ${persisting.length} still present`);
116
+ for (const f of resolved.slice(0, 8)) log(` resolved [${f.severity}] ${f.title}`);
117
+ for (const f of added.slice(0, 8)) log(` new [${f.severity}] ${f.title}`);
118
+ const concerns = c.checks.filter(x => x.level === 'warn' || x.level === 'bad');
119
+ if (concerns.length) {
120
+ log('\nComparability:');
121
+ for (const x of concerns) log(` ! ${x.title}${x.detail ? ` — ${x.detail}` : ''}`);
122
+ }
123
+
124
+ writeResult(result, { json, out });
125
+ if (out) log(`\nResult written to ${path.resolve(out)}`);
126
+ exitAfterFlush(flag('fail-on-regression') && isRegression(result) ? EXIT.CHECKS_FAILED : EXIT.OK);
@@ -2,59 +2,73 @@
2
2
  * CLI wrapper for the evaluation orchestrator — the default action of
3
3
  * `spectra performance`. Runs the golden-path suite against --url, prints the
4
4
  * performance matrix, and saves an aggregate report alongside the per-path
5
- * reports (all openable in the dashboard).
5
+ * reports (all openable in the dashboard). With --json / --out it also emits
6
+ * the machine-readable result, and budgets from spectra.config.json are
7
+ * checked against every journey.
6
8
  */
7
9
  import fs from 'node:fs';
8
10
  import path from 'node:path';
9
11
  import { runEvaluation } from './evaluate.js';
12
+ import { readReport } from '../recorder.js';
10
13
  import { REPORTS_DIR, TARGET_NAME } from '../config.js';
14
+ import { parseArgs } from '../args.js';
15
+ import { loadProjectConfig } from '../project-config.js';
16
+ import { EXIT, SPECTRA_VERSION, exitAfterFlush, printBudgets, reportFile, writeResult } from '../output.js';
17
+ import { buildEvaluationResult } from '../../shared/result.js';
11
18
 
12
- const args = process.argv.slice(2);
13
- const opt = (name, fallback) => {
14
- const i = args.indexOf(`--${name}`);
15
- return i === -1 ? fallback : args[i + 1] ?? true;
16
- };
17
- const flag = name => args.includes(`--${name}`);
19
+ const { opt, flag } = parseArgs(process.argv.slice(2));
18
20
 
19
21
  const url = opt('url');
20
22
  if (!url) {
21
23
  console.error('spectra: evaluate requires --url');
22
- process.exit(1);
24
+ process.exit(EXIT.ERROR);
25
+ }
26
+ const json = flag('json');
27
+ const out = opt('out');
28
+ // In --json mode stdout carries only the result; people still see the matrix.
29
+ const log = json ? console.error : console.log;
30
+
31
+ let config;
32
+ try {
33
+ config = loadProjectConfig(opt('config'));
34
+ } catch (err) {
35
+ console.error(`spectra: ${err.message}`);
36
+ process.exit(EXIT.ERROR);
23
37
  }
24
38
 
25
39
  const pad = (s, n) => String(s ?? '').padEnd(n).slice(0, n);
26
40
  const padL = (s, n) => String(s ?? '').padStart(n);
27
- const ms = v => (v == null ? '—' : `${v}ms`);
41
+ const ms = v => (v == null ? '—' : `${Math.round(v)}ms`);
28
42
 
29
43
  function printMatrix(result) {
30
44
  const rows = [result.baseline, ...result.paths];
31
- console.log(`\n${'═'.repeat(78)}`);
32
- console.log(`Spectra performance evaluation — ${result.target}`);
33
- console.log(`${result.url}`);
34
- console.log(
45
+ log(`\n${'═'.repeat(78)}`);
46
+ log(`Spectra performance evaluation — ${result.target}`);
47
+ log(`${result.url}`);
48
+ log(
35
49
  `${result.discovered} actionable elements · ${result.paths.length} golden paths · ` +
36
50
  `${result.skippedDestructive.length} destructive skipped (safe mode)`,
37
51
  );
38
- console.log('═'.repeat(78));
39
- console.log(
52
+ log('═'.repeat(78));
53
+ log(
40
54
  `${pad('#', 3)}${pad('Golden path', 34)}${pad('Result', 8)}${padL('TBT', 7)}${padL('LCP', 8)}${padL('LongT', 7)} Top finding`,
41
55
  );
42
- console.log('─'.repeat(78));
56
+ log('─'.repeat(78));
43
57
  rows.forEach((r, i) => {
44
58
  const num = i === 0 ? '·' : String(i);
45
59
  const status = r.ok ? 'PASS' : 'FAIL';
46
60
  const s = r.summary || {};
47
61
  const top = s.topFinding ? `[${s.topFinding.severity}] ${s.topFinding.title}` : '—';
48
- console.log(
62
+ log(
49
63
  `${pad(num, 3)}${pad(r.title, 34)}${pad(status, 8)}${padL(ms(s.tbtMs), 7)}${padL(ms(s.lcpMs), 8)}${padL(s.longTaskCount ?? '—', 7)} ${top.slice(0, 40)}`,
50
64
  );
51
- if (!r.ok && r.error) console.log(` ↳ ${r.error}`);
65
+ if (!r.ok && r.error) log(` ↳ ${r.error}`);
52
66
  });
53
- console.log('─'.repeat(78));
67
+ log('─'.repeat(78));
54
68
  const passed = result.paths.filter(p => p.ok).length;
55
- console.log(`Automation: ${passed}/${result.paths.length} golden paths passed.`);
69
+ log(`Automation: ${passed}/${result.paths.length} golden paths passed.`);
56
70
  if (result.skippedDestructive.length) {
57
- console.log(
71
+ log(
58
72
  `Skipped (safe mode): ${result.skippedDestructive.map(s => s.name).join(', ')}`,
59
73
  );
60
74
  }
@@ -66,22 +80,28 @@ function stamp() {
66
80
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}T${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
67
81
  }
68
82
 
69
- const result = await runEvaluation({
70
- url,
71
- headless: !flag('headed'),
72
- limit: Number(opt('paths', 12)),
73
- signIn: opt('sign-in'),
74
- onProgress: ev => {
75
- if (ev.phase === 'baseline') console.error('spectra: recording baseline page load …');
76
- else if (ev.phase === 'discover') console.error('spectra: discovering actionable elements …');
77
- else if (ev.phase === 'synthesized')
78
- console.error(
79
- `spectra: ${ev.discovered} actionable elements (${ev.skipped} destructive skipped) → ${ev.paths} golden paths`,
80
- );
81
- else if (ev.phase === 'path')
82
- console.error(`spectra: [${ev.index}/${ev.total}] ${ev.title}`);
83
- },
84
- });
83
+ let result;
84
+ try {
85
+ result = await runEvaluation({
86
+ url,
87
+ headless: !flag('headed'),
88
+ limit: Number(opt('paths', 12)),
89
+ signIn: opt('sign-in'),
90
+ onProgress: ev => {
91
+ if (ev.phase === 'baseline') console.error('spectra: recording baseline page load …');
92
+ else if (ev.phase === 'discover') console.error('spectra: discovering actionable elements …');
93
+ else if (ev.phase === 'synthesized')
94
+ console.error(
95
+ `spectra: ${ev.discovered} actionable elements (${ev.skipped} destructive skipped) → ${ev.paths} golden paths`,
96
+ );
97
+ else if (ev.phase === 'path')
98
+ console.error(`spectra: [${ev.index}/${ev.total}] ${ev.title}`);
99
+ },
100
+ });
101
+ } catch (err) {
102
+ console.error(`spectra: ${err.message}`);
103
+ process.exit(EXIT.ERROR);
104
+ }
85
105
 
86
106
  printMatrix(result);
87
107
 
@@ -93,9 +113,21 @@ fs.writeFileSync(
93
113
  JSON.stringify({ id, kind: 'evaluation', createdAt: Date.now(), target: TARGET_NAME, ...result }, null, 2),
94
114
  );
95
115
 
116
+ const evaluation = buildEvaluationResult({
117
+ evaluation: result,
118
+ readReport,
119
+ fileOf: reportFile,
120
+ file,
121
+ spectraVersion: SPECTRA_VERSION,
122
+ budgets: config.budgets,
123
+ });
124
+ printBudgets(evaluation.budgets, log);
125
+ writeResult(evaluation, { json, out });
126
+
96
127
  const installed = !!process.env.PERF_REPORTS_DIR;
97
- console.log(`\nSaved ${file}`);
98
- console.log(
128
+ log(`\nSaved ${file}`);
129
+ log(
99
130
  `Per-path reports are in ${REPORTS_DIR} — view them with: ${installed ? 'spectra dashboard' : 'npm run dev'}`,
100
131
  );
101
- process.exit(0);
132
+ if (out) log(`Result written to ${path.resolve(out)}`);
133
+ exitAfterFlush(evaluation.budgets && !evaluation.budgets.ok ? EXIT.CHECKS_FAILED : EXIT.OK);
@@ -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
+ }