@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.
@@ -721,6 +721,118 @@
721
721
  probe.frameworkName = 'vue';
722
722
  }
723
723
 
724
+ // ------------------------------------------------ Angular adapter
725
+ // Uses Angular's dev-mode framework profiler (window.ng.ɵsetProfiler, present
726
+ // when ngDevMode is on). Component change-detection fires paired start/end
727
+ // events (2/3) carrying the component instance; a nesting stack turns those
728
+ // into per-component render counts and self time (dur minus children), just
729
+ // like React/Vue. The ng global appears only after bootstrap, so we poll.
730
+ function installAngularAdapter() {
731
+ var ngIds = new Map();
732
+ var nextId = 100000; // keep clear of other adapters' id space
733
+ var stack = [];
734
+
735
+ function clean(n) {
736
+ return (n || '').replace(/^_+/, '');
737
+ }
738
+ function isComponent(instance) {
739
+ try {
740
+ var ctor = instance && instance.constructor;
741
+ if (!ctor) return false;
742
+ return !!ctor['ɵcmp'] || /Component$/.test(ctor.name || '');
743
+ } catch (e) {
744
+ return false;
745
+ }
746
+ }
747
+ function getStat(ctor, name) {
748
+ var id = ngIds.get(ctor);
749
+ if (id === undefined) {
750
+ id = nextId++;
751
+ ngIds.set(ctor, id);
752
+ }
753
+ var stat = probe.stats[id];
754
+ if (!stat) {
755
+ stat = probe.stats[id] = {
756
+ id: id,
757
+ name: name,
758
+ kind: 'angular',
759
+ renders: 0,
760
+ mounts: 0,
761
+ wasted: 0,
762
+ selfMs: 0,
763
+ totalMs: 0,
764
+ maxMs: 0,
765
+ reasons: { props: 0, state: 0, hooks: 0, context: 0, parent: 0 },
766
+ props: {},
767
+ parents: {},
768
+ file: '',
769
+ };
770
+ }
771
+ return stat;
772
+ }
773
+ function onEvent(event, instance) {
774
+ if (!probe.active) return;
775
+ if (event === 2) {
776
+ // Component change-detection start. Push a frame (null for non-component
777
+ // instances like structural-directive contexts) to keep the stack balanced.
778
+ if (!isComponent(instance)) {
779
+ stack.push(null);
780
+ return;
781
+ }
782
+ if (stack.length === 0) probe.commitCount = (probe.commitCount || 0) + 1;
783
+ stack.push({
784
+ ctor: instance.constructor,
785
+ name: clean(instance.constructor.name),
786
+ start: performance.now(),
787
+ child: 0,
788
+ });
789
+ } else if (event === 3) {
790
+ var f = stack.pop();
791
+ if (!f) return;
792
+ var dur = performance.now() - f.start;
793
+ var self = dur - f.child;
794
+ if (self < 0) self = 0;
795
+ for (var i = stack.length - 1; i >= 0; i--) {
796
+ if (stack[i]) {
797
+ stack[i].child += dur;
798
+ break;
799
+ }
800
+ }
801
+ var stat = getStat(f.ctor, f.name);
802
+ stat.renders++;
803
+ stat.selfMs += self;
804
+ stat.totalMs += dur;
805
+ if (self > stat.maxMs) stat.maxMs = self;
806
+ probe.sawTimings = true;
807
+ }
808
+ }
809
+ function register() {
810
+ try {
811
+ var ng = window.ng;
812
+ if (ng && typeof ng['ɵsetProfiler'] === 'function') {
813
+ ng['ɵsetProfiler'](function (event, instance) {
814
+ try {
815
+ onEvent(event, instance);
816
+ } catch (e) {}
817
+ });
818
+ probe.frameworkName = 'angular';
819
+ probe.frameworkDetected = true;
820
+ probe.frameworkVersion =
821
+ (window.ng && window.ng.ɵgetInjectorMetadata ? '' : '') || probe.frameworkVersion;
822
+ return true;
823
+ }
824
+ } catch (e) {}
825
+ return false;
826
+ }
827
+ if (!register()) {
828
+ var tries = 0;
829
+ var iv = setInterval(function () {
830
+ tries++;
831
+ if (register() || tries > 200) clearInterval(iv); // poll up to ~8s
832
+ }, 40);
833
+ }
834
+ }
835
+
724
836
  // ------------------------------------------------ Framework fallbacks
725
837
  // Provide lightweight probes for Vue, Svelte and Angular when React is not detected.
726
838
  function installFrameworkProbes() {
@@ -822,7 +934,8 @@
822
934
  try {
823
935
  var fwHint = SPECTRA_FRAMEWORK && SPECTRA_FRAMEWORK.framework;
824
936
  if (fwHint === 'vue') installVueAdapter();
825
- else if (!WANT_REACT) installFrameworkProbes(); // svelte/angular: heuristic for now
937
+ else if (fwHint === 'angular') installAngularAdapter();
938
+ else if (!WANT_REACT) installFrameworkProbes(); // svelte: heuristic for now
826
939
  } catch (e) {}
827
940
  installObservers();
828
941
  })();
@@ -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,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`;