@progen-labs/spectra 0.0.0-stage → 0.1.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-BvBsPok5.js +46 -0
  5. package/dist/assets/index-Da3Le5Px.css +1 -0
  6. package/dist/index.html +14 -0
  7. package/index.html +13 -0
  8. package/package.json +76 -4
  9. package/server/analysis/cpu.js +369 -0
  10. package/server/analysis/findings.js +551 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +750 -0
  13. package/server/analysis/sources.js +249 -0
  14. package/server/analysis/trace.js +314 -0
  15. package/server/api.js +292 -0
  16. package/server/chrome.js +81 -0
  17. package/server/cli.js +94 -0
  18. package/server/config.js +59 -0
  19. package/server/dashboard.js +154 -0
  20. package/server/explore/boot.js +150 -0
  21. package/server/explore/classify.js +169 -0
  22. package/server/explore/discover.js +162 -0
  23. package/server/explore/evaluate-cli.js +101 -0
  24. package/server/explore/evaluate.js +165 -0
  25. package/server/explore/paths.js +105 -0
  26. package/server/explore/runner.js +94 -0
  27. package/server/explore/test-explore.js +139 -0
  28. package/server/explore/test-paths.js +75 -0
  29. package/server/framework.js +67 -0
  30. package/server/git.js +59 -0
  31. package/server/pdf/render.js +60 -0
  32. package/server/pdf/template.js +419 -0
  33. package/server/plugin.js +19 -0
  34. package/server/probe.browser.js +689 -0
  35. package/server/recorder.js +610 -0
  36. package/server/signin.js +27 -0
  37. package/server/test-analysis.js +424 -0
  38. package/server/test-compare.js +473 -0
  39. package/server/test-sources.js +136 -0
  40. package/shared/compare.js +977 -0
  41. package/shared/snapshot.js +77 -0
  42. package/src/App.jsx +361 -0
  43. package/src/api.js +51 -0
  44. package/src/components/AppearanceMenu.jsx +76 -0
  45. package/src/components/CodeRef.jsx +48 -0
  46. package/src/components/ComparePanel.jsx +143 -0
  47. package/src/components/CompareResults.jsx +473 -0
  48. package/src/components/ControlBar.jsx +336 -0
  49. package/src/components/CulpritSphere.jsx +218 -0
  50. package/src/components/Culprits.jsx +118 -0
  51. package/src/components/CulpritsHero.jsx +109 -0
  52. package/src/components/EvaluationPanel.jsx +160 -0
  53. package/src/components/FilePanel.jsx +225 -0
  54. package/src/components/FindingsList.jsx +381 -0
  55. package/src/components/HeatTrees.jsx +222 -0
  56. package/src/components/Icons.jsx +58 -0
  57. package/src/components/LongTasks.jsx +199 -0
  58. package/src/components/NetworkPanel.jsx +98 -0
  59. package/src/components/PageMap.jsx +329 -0
  60. package/src/components/Popover.jsx +51 -0
  61. package/src/components/ReactPanel.jsx +200 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/TimeBreakdown.jsx +45 -0
  65. package/src/components/TraceButton.jsx +76 -0
  66. package/src/components/TreeView.jsx +173 -0
  67. package/src/components/Vitals.jsx +163 -0
  68. package/src/culprits.js +127 -0
  69. package/src/download.js +18 -0
  70. package/src/format.js +26 -0
  71. package/src/heat.js +177 -0
  72. package/src/highlight.js +45 -0
  73. package/src/hooks/useCompare.js +187 -0
  74. package/src/hooks/useSession.js +73 -0
  75. package/src/layout.js +55 -0
  76. package/src/main.jsx +15 -0
  77. package/src/styles.css +2478 -0
  78. package/src/theme.js +75 -0
  79. package/src/themes.css +286 -0
  80. package/vite.config.js +8 -0
@@ -0,0 +1,424 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import {
4
+ analyzeCpuProfile,
5
+ extractCpuProfile,
6
+ windowSelf,
7
+ } from './analysis/cpu.js';
8
+ import { buildFindings } from './analysis/findings.js';
9
+ import { analyzeTrace, findMainThread } from './analysis/trace.js';
10
+
11
+ const PID = 1;
12
+ const TID = 2;
13
+ const meta = {
14
+ ph: 'M',
15
+ name: 'thread_name',
16
+ pid: PID,
17
+ tid: TID,
18
+ args: { name: 'CrRendererMain' },
19
+ };
20
+ const x = (name, ts, dur, args = {}) => ({
21
+ ph: 'X',
22
+ name,
23
+ pid: PID,
24
+ tid: TID,
25
+ ts,
26
+ dur,
27
+ args,
28
+ cat: 'devtools.timeline',
29
+ });
30
+
31
+ test('long tasks are broken down by category and blamed on their trigger', () => {
32
+ const events = [
33
+ meta,
34
+ x('RunTask', 0, 10_000),
35
+ x('RunTask', 20_000, 120_000),
36
+ x('EventDispatch', 20_500, 110_000, { data: { type: 'click' } }),
37
+ x('FunctionCall', 21_000, 100_000, {
38
+ data: { functionName: 'onClick', url: 'a.js' },
39
+ }),
40
+ x('Layout', 90_000, 20_000, {
41
+ beginData: { totalObjects: 500, dirtyObjects: 3 },
42
+ }),
43
+ x('UpdateLayoutTree', 112_000, 6_000),
44
+ ];
45
+ const t = analyzeTrace(events, findMainThread(events));
46
+ assert.equal(t.tasks.length, 2);
47
+ assert.equal(t.longTasks.length, 1);
48
+ const [long] = t.longTasks;
49
+ assert.equal(long.trigger.type, 'click handler');
50
+ assert.equal(long.breakdown.layout, 20_000);
51
+ assert.equal(long.breakdown.style, 6_000);
52
+ // EventDispatch self 10ms + FunctionCall self 74ms (its nested layout and style are counted separately)
53
+ assert.equal(long.breakdown.scripting, 84_000);
54
+ assert.equal(t.maxLayoutObjects, 500);
55
+ });
56
+
57
+ test('layout inside script is a forced reflow; three in one task is thrashing', () => {
58
+ const events = [
59
+ meta,
60
+ x('RunTask', 0, 100_000),
61
+ x('FunctionCall', 100, 90_000),
62
+ ];
63
+ for (let i = 0; i < 4; i += 1)
64
+ events.push(
65
+ x('Layout', 1000 + i * 10_000, 2000, {
66
+ beginData: {
67
+ stackTrace: [
68
+ {
69
+ functionName: 'measure',
70
+ url: 'a.js',
71
+ scriptId: '7',
72
+ lineNumber: 4,
73
+ columnNumber: 2,
74
+ },
75
+ ],
76
+ },
77
+ }),
78
+ );
79
+ events.push(x('Layout', 95_000, 1000)); // outside script: normal layout
80
+ const t = analyzeTrace(events, findMainThread(events));
81
+ assert.equal(t.forced.length, 4);
82
+ assert.equal(t.forced[0].stack[0].functionName, 'measure');
83
+ assert.equal(t.tasks[0].thrash, true);
84
+ assert.equal(t.counts.layout, 5);
85
+ });
86
+
87
+ test('CPU profile chunks are paired with their header and attributed to files', () => {
88
+ // Real traces: the Profile header is on the main thread, chunks come from V8's helper thread.
89
+ const HELPER = 77;
90
+ const chunk = (id, nodes, samples, deltas, ts = 0) => ({
91
+ ph: 'P',
92
+ name: 'ProfileChunk',
93
+ pid: PID,
94
+ tid: HELPER,
95
+ id,
96
+ ts,
97
+ args: { data: { cpuProfile: { nodes, samples }, timeDeltas: deltas } },
98
+ });
99
+ const events = [
100
+ {
101
+ ph: 'P',
102
+ name: 'Profile',
103
+ pid: PID,
104
+ tid: TID,
105
+ id: '0x1',
106
+ ts: 5,
107
+ args: { data: { startTime: 1000 } },
108
+ },
109
+ // a second profiler in the same process (e.g. a WebUI page) reuses id 0x1 and node ids
110
+ {
111
+ ph: 'P',
112
+ name: 'Profile',
113
+ pid: PID,
114
+ tid: 99,
115
+ id: '0x1',
116
+ ts: 6,
117
+ args: { data: { startTime: 50 } },
118
+ },
119
+ chunk(
120
+ '0x1',
121
+ [
122
+ {
123
+ id: 1,
124
+ callFrame: { functionName: '(root)', url: '', scriptId: '0' },
125
+ },
126
+ {
127
+ id: 2,
128
+ parent: 1,
129
+ callFrame: {
130
+ functionName: 'render',
131
+ url: 'http://h/src/a.js',
132
+ scriptId: '1',
133
+ lineNumber: 3,
134
+ },
135
+ },
136
+ {
137
+ id: 3,
138
+ parent: 2,
139
+ callFrame: {
140
+ functionName: 'work',
141
+ url: 'http://h/src/b.js',
142
+ scriptId: '2',
143
+ lineNumber: 1,
144
+ },
145
+ },
146
+ {
147
+ id: 4,
148
+ parent: 3,
149
+ callFrame: { functionName: 'sort', url: '', scriptId: '0' },
150
+ },
151
+ {
152
+ id: 5,
153
+ parent: 1,
154
+ callFrame: { functionName: '(idle)', url: '', scriptId: '0' },
155
+ },
156
+ ],
157
+ [3, 3, 4, 5, 5],
158
+ [0, 1000, 1000, 1000, 1000],
159
+ 10,
160
+ ),
161
+ chunk(
162
+ '0x1',
163
+ [
164
+ {
165
+ id: 1,
166
+ callFrame: { functionName: '(root)', url: '', scriptId: '0' },
167
+ },
168
+ {
169
+ id: 2,
170
+ parent: 1,
171
+ callFrame: { functionName: 'other', url: 'x', scriptId: '9' },
172
+ },
173
+ ],
174
+ [2],
175
+ [10],
176
+ 11,
177
+ ),
178
+ ];
179
+ const profile = extractCpuProfile(events, PID, TID);
180
+ assert.equal(profile.startTime, 1000);
181
+ assert.equal(profile.samples.length, 5);
182
+ const cpu = analyzeCpuProfile(profile, cf => ({
183
+ file: cf.url.replace('http://h/', ''),
184
+ vendor: false,
185
+ pkg: null,
186
+ line: cf.lineNumber + 1,
187
+ name: cf.functionName,
188
+ exact: true,
189
+ }));
190
+ const byFile = Object.fromEntries(cpu.files.map(f => [f.file, f]));
191
+ // 'sort' is a builtin (no url): its time is blamed on the caller, b.js
192
+ assert.equal(byFile['src/b.js'].selfUs, 3000);
193
+ assert.equal(byFile['src/a.js'].selfUs, 0);
194
+ assert.equal(byFile['src/a.js'].totalUs, 3000);
195
+ // timestamps are on the trace clock: startTime + cumulative deltas
196
+ const inWindow = windowSelf(cpu, 1000, 3500);
197
+ assert.equal([...inWindow.values()].reduce((a, b) => a + b, 0) > 0, true);
198
+ });
199
+
200
+ test('findings rank critical React and layout problems with concrete fixes', () => {
201
+ const base = {
202
+ vitals: {
203
+ tbtMs: 0,
204
+ longTaskCount: 0,
205
+ maxLongTaskMs: 0,
206
+ cls: 0,
207
+ shifts: [],
208
+ lcpMs: null,
209
+ fcpMs: null,
210
+ },
211
+ files: [],
212
+ longTasks: [],
213
+ forcedReflows: [],
214
+ thrashTasks: 0,
215
+ interactions: [],
216
+ totals: { activeMs: 1000, gcMs: 0, compileMs: 0 },
217
+ memory: { domNodes: 100, listeners: 10, heapGrowthMb: 0 },
218
+ network: { uncompressed: [], large: [], slow: [] },
219
+ meta: { durationMs: 5000 },
220
+ react: {
221
+ detected: true,
222
+ hasTimings: true,
223
+ slowCommits: [],
224
+ components: [
225
+ {
226
+ name: 'Row',
227
+ file: 'src/Row.tsx',
228
+ line: 3,
229
+ renders: 100,
230
+ mounts: 1,
231
+ wasted: 80,
232
+ selfMs: 50,
233
+ totalMs: 60,
234
+ wastedMs: 40,
235
+ reasons: {},
236
+ unstableProps: [{ prop: 'onPick', kind: 'function', count: 90 }],
237
+ topParent: 'List',
238
+ },
239
+ ],
240
+ },
241
+ };
242
+ const findings = buildFindings(base);
243
+ const wasted = findings.find(f => f.id.startsWith('wasted-'));
244
+ assert.equal(wasted.severity, 'critical');
245
+ assert.match(wasted.fix, /React\.memo/);
246
+ assert.ok(
247
+ findings.some(
248
+ f => f.id.startsWith('unstable-') && /useCallback/.test(f.fix),
249
+ ),
250
+ );
251
+ });
252
+
253
+ test('a recording of an HTTP error page leads with a critical setup finding', () => {
254
+ const report = {
255
+ vitals: {
256
+ tbtMs: 0,
257
+ longTaskCount: 0,
258
+ maxLongTaskMs: 0,
259
+ cls: 0,
260
+ shifts: [],
261
+ lcpMs: null,
262
+ fcpMs: null,
263
+ },
264
+ files: [],
265
+ longTasks: [],
266
+ forcedReflows: [],
267
+ thrashTasks: 0,
268
+ interactions: [],
269
+ totals: { activeMs: 10, gcMs: 0, compileMs: 0 },
270
+ memory: { domNodes: 5, listeners: 0, heapGrowthMb: 0 },
271
+ network: { uncompressed: [], large: [], slow: [] },
272
+ meta: {
273
+ durationMs: 3000,
274
+ navStatus: 500,
275
+ navUrl: 'http://localhost:3000/login/',
276
+ },
277
+ react: {
278
+ detected: false,
279
+ hasTimings: false,
280
+ slowCommits: [],
281
+ components: [],
282
+ },
283
+ };
284
+ const [first] = buildFindings(report);
285
+ assert.equal(first.id, 'page-failed');
286
+ assert.equal(first.severity, 'critical');
287
+ assert.match(first.title, /HTTP 500/);
288
+ });
289
+
290
+ test('function definitions are located by name in the real source', async () => {
291
+ const { findDefinitionLine, searchableName } = await import(
292
+ './analysis/locate.js'
293
+ );
294
+ const source = [
295
+ "import x from 'y';",
296
+ 'const helper = () => 1;',
297
+ 'export function buildRows(data) {',
298
+ ' return data.map(d => d.id);',
299
+ '}',
300
+ 'class Grid {',
301
+ ' componentDidUpdate(prev) {',
302
+ ' this.run(prev);',
303
+ ' }',
304
+ '}',
305
+ 'export const Toolbar = memo(function Toolbar() {});',
306
+ ].join('\n');
307
+ assert.equal(findDefinitionLine(source, 'buildRows'), 3);
308
+ assert.equal(findDefinitionLine(source, 'Grid'), 6);
309
+ assert.equal(findDefinitionLine(source, 'Grid.componentDidUpdate'), 7);
310
+ assert.equal(findDefinitionLine(source, 'helper'), 2);
311
+ assert.equal(findDefinitionLine(source, 'Toolbar'), 11);
312
+ assert.equal(findDefinitionLine(source, '(anonymous in buildRows)'), 3);
313
+ assert.equal(findDefinitionLine(source, 'nope'), null);
314
+ // a bare call is not a definition
315
+ assert.equal(findDefinitionLine(' run(prev);\nfunction run() {}', 'run'), 2);
316
+ assert.equal(searchableName('(anonymous)'), null);
317
+ });
318
+
319
+ test('anonymous callbacks are named after the function they live in', () => {
320
+ const events = [
321
+ {
322
+ ph: 'P',
323
+ name: 'Profile',
324
+ pid: 1,
325
+ tid: 2,
326
+ id: '0x1',
327
+ ts: 0,
328
+ args: { data: { startTime: 0 } },
329
+ },
330
+ {
331
+ ph: 'P',
332
+ name: 'ProfileChunk',
333
+ pid: 1,
334
+ tid: 2,
335
+ id: '0x1',
336
+ ts: 0,
337
+ args: {
338
+ data: {
339
+ cpuProfile: {
340
+ nodes: [
341
+ {
342
+ id: 1,
343
+ callFrame: { functionName: '(root)', url: '', scriptId: '0' },
344
+ },
345
+ {
346
+ id: 2,
347
+ parent: 1,
348
+ callFrame: {
349
+ functionName: 'renderRows',
350
+ url: 'http://h/src/a.js',
351
+ scriptId: '1',
352
+ lineNumber: 4,
353
+ },
354
+ },
355
+ {
356
+ id: 3,
357
+ parent: 2,
358
+ callFrame: {
359
+ functionName: '',
360
+ url: 'http://h/src/a.js',
361
+ scriptId: '1',
362
+ lineNumber: 6,
363
+ },
364
+ },
365
+ {
366
+ id: 4,
367
+ parent: 1,
368
+ callFrame: {
369
+ functionName: 'lodashSort',
370
+ url: 'http://h/node_modules/lodash/lodash.js',
371
+ scriptId: '2',
372
+ lineNumber: 1,
373
+ },
374
+ },
375
+ ],
376
+ samples: [3, 3, 3],
377
+ },
378
+ timeDeltas: [0, 1000, 1000],
379
+ },
380
+ },
381
+ },
382
+ ];
383
+ // renderRows -> (anonymous); lodash called directly under the root has no app caller
384
+ const profile = extractCpuProfile(events, 1, 2);
385
+ profile.nodes.find(n => n.id === 4).parent = 3; // make lodash a callee of the anonymous callback
386
+ profile.samples = [4, 4, 3];
387
+ const cpu = analyzeCpuProfile(profile, cf => ({
388
+ file: cf.url.replace('http://h/', ''),
389
+ vendor: cf.url.includes('node_modules'),
390
+ pkg: cf.url.includes('node_modules') ? 'lodash' : null,
391
+ line: cf.lineNumber + 1,
392
+ name: cf.functionName,
393
+ exact: false,
394
+ }));
395
+ const names = cpu.functions.map(f => f.name);
396
+ assert.ok(names.includes('(anonymous in renderRows)'));
397
+ const drivers = [...cpu.vendorDrivers.get('lodash').values()];
398
+ assert.equal(drivers[0].name, '(anonymous in renderRows)');
399
+ });
400
+
401
+ test('sign-in URLs embed the page to return to and detect a signed-out landing', async () => {
402
+ const { buildSignInUrl, looksSignedOut } = await import('./signin.js');
403
+ assert.equal(
404
+ buildSignInUrl(
405
+ 'http://h/login/?redirect={url}',
406
+ 'http://h/path/to/page/?edit=true&x=1#top',
407
+ ),
408
+ 'http://h/login/?redirect=%2Fpath%2Fto%2Fpage%2F%3Fedit%3Dtrue%26x%3D1%23top',
409
+ );
410
+ assert.equal(buildSignInUrl('', 'http://h/p'), null);
411
+ assert.equal(
412
+ buildSignInUrl('http://h/login', 'http://h/p'),
413
+ 'http://h/login',
414
+ );
415
+ assert.equal(
416
+ looksSignedOut({ status: 500 }, 'http://h/login/?next=/p'),
417
+ true,
418
+ );
419
+ assert.equal(looksSignedOut(null, 'http://h/login/?next=/p'), true);
420
+ assert.equal(
421
+ looksSignedOut({ status: 200 }, 'http://h/path/to/page/1'),
422
+ false,
423
+ );
424
+ });