@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.
- package/README.md +95 -8
- package/bin/spectra.js +87 -7
- package/dist/assets/{index-COnXcwA_.js → index-DY8VmG9y.js} +1 -1
- package/dist/assets/index-Dx1YSFIx.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +9 -6
- package/schema/config.schema.json +94 -0
- package/schema/result.schema.json +749 -0
- package/server/analysis/findings.js +8 -4
- package/server/args.js +29 -0
- package/server/cli.js +143 -67
- package/server/compare-cli.js +126 -0
- package/server/explore/evaluate-cli.js +72 -40
- package/server/output.js +50 -0
- package/server/probe.browser.js +114 -1
- package/server/project-config.js +30 -0
- package/server/recorder.js +4 -1
- package/server/reports-cli.js +31 -0
- package/server/test-result.js +226 -0
- package/shared/compare.js +1 -0
- package/shared/result.js +291 -0
- package/src/styles.css +20 -3
- package/dist/assets/index-DxxJOPn9.css +0 -1
|
@@ -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 === '
|
|
245
|
-
? '
|
|
246
|
-
:
|
|
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
|
-
:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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 ?
|
|
30
|
+
process.exit(url ? EXIT.OK : EXIT.ERROR);
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
const
|
|
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
|
-
|
|
39
|
+
let mode, runs, duration, cpuThrottle, config, scenario;
|
|
34
40
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
117
|
+
log(
|
|
118
|
+
reports.length > 1
|
|
119
|
+
? `\nCulprits (from ${rep.id}, the most typical run):`
|
|
120
|
+
: '\nCulprits:',
|
|
67
121
|
);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
65
|
+
if (!r.ok && r.error) log(` ↳ ${r.error}`);
|
|
52
66
|
});
|
|
53
|
-
|
|
67
|
+
log('─'.repeat(78));
|
|
54
68
|
const passed = result.paths.filter(p => p.ok).length;
|
|
55
|
-
|
|
69
|
+
log(`Automation: ${passed}/${result.paths.length} golden paths passed.`);
|
|
56
70
|
if (result.skippedDestructive.length) {
|
|
57
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.error(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
132
|
+
if (out) log(`Result written to ${path.resolve(out)}`);
|
|
133
|
+
exitAfterFlush(evaluation.budgets && !evaluation.budgets.ok ? EXIT.CHECKS_FAILED : EXIT.OK);
|
package/server/output.js
ADDED
|
@@ -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
|
+
}
|