@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,249 @@
1
+ /**
2
+ * Maps a position inside a running script to a file in the repo.
3
+ *
4
+ * Strategies, tried per script in order of precision:
5
+ * 1. source map exact original file, line and function name
6
+ * 2. webpack markers dev bundles built with `devtool: false` (this repo's
7
+ * default) keep a `/***\/ "./src/x.tsx":` line in front of
8
+ * every module, so a bundle line maps to its module
9
+ * 3. script URL Vite/ESM dev servers serve one script per file;
10
+ * `webpack://` and `webpack-internal://` URLs embed the path
11
+ * 4. bundle anything else is reported as the bundle it came from
12
+ */
13
+ import path from 'node:path';
14
+ import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
15
+
16
+ const posix = path.posix;
17
+ const MARKER = /^\/\*{3,}\/ "([^"]+)":\s*$/;
18
+ // The banner webpack prints above every module, including the inlined entry module
19
+ // (which has no quoted marker): !*** ./src/main.jsx ***!
20
+ const BANNER = /^\s*!\*{3} (\S.*?) \*{3}!\s*$/;
21
+ const NATIVE = new Set([
22
+ '(root)',
23
+ '(program)',
24
+ '(idle)',
25
+ '(garbage collector)',
26
+ ]);
27
+
28
+ export function isNativeFrame(functionName) {
29
+ return NATIVE.has(functionName);
30
+ }
31
+
32
+ /** 'node_modules/@a/b/c.js' -> '@a/b', 'node_modules/x/y.js' -> 'x' */
33
+ export function packageOf(file) {
34
+ const i = file.lastIndexOf('node_modules/');
35
+ if (i === -1) return null;
36
+ const rest = file.slice(i + 'node_modules/'.length).split('/');
37
+ if (rest[0] === '.vite') {
38
+ // Vite pre-bundles dependencies: .vite/deps/react-dom_client.js -> react-dom
39
+ const name = (rest[rest.length - 1] || '')
40
+ .replace(/\.js$/, '')
41
+ .replace(/_.*$/, '');
42
+ return name.startsWith('chunk-') ? 'vite-deps' : name;
43
+ }
44
+ return rest[0].startsWith('@') ? rest.slice(0, 2).join('/') : rest[0];
45
+ }
46
+
47
+ /**
48
+ * Normalises any of the path spellings bundlers produce to a repo-relative
49
+ * posix path ('src/a/b.tsx', 'node_modules/react/index.js').
50
+ */
51
+ export function normalizePath(raw, root) {
52
+ let p = raw;
53
+ p = p
54
+ .replace(/^webpack-internal:\/\/\/?/, '')
55
+ .replace(/^webpack:\/\/[^/]*\//, '');
56
+ p = p.replace(/^file:\/\//, '');
57
+ p = p.replace(/[?#].*$/, '').split(' + ')[0];
58
+ if (p.startsWith('/@fs/')) p = p.slice(4);
59
+ const nm = p.lastIndexOf('node_modules/');
60
+ if (nm !== -1) return p.slice(nm);
61
+ if (root && posix.isAbsolute(p) && p.startsWith(`${root}/`))
62
+ p = p.slice(root.length + 1);
63
+ p = posix
64
+ .normalize(p)
65
+ .replace(/^(\.\.\/)+/, '')
66
+ .replace(/^\.\//, '')
67
+ .replace(/^\//, '');
68
+ return p;
69
+ }
70
+
71
+ function pathFromUrl(url, root) {
72
+ try {
73
+ const u = new URL(url);
74
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
75
+ return normalizePath(decodeURIComponent(u.pathname), root);
76
+ }
77
+ } catch {
78
+ /* not an absolute URL */
79
+ }
80
+ return normalizePath(url, root);
81
+ }
82
+
83
+ function looksLikeBundle(p) {
84
+ return (
85
+ /\.(?:chunk|bundle)\.js$|(?:^|\/)(?:static\/assets|assets|dist)\//.test(
86
+ p,
87
+ ) || /\.[0-9a-f]{8,}\.js$/.test(p)
88
+ );
89
+ }
90
+
91
+ function decodeDataUrl(url) {
92
+ const m = /^data:[^,]*?(;base64)?,(.*)$/s.exec(url);
93
+ if (!m) return null;
94
+ return m[1]
95
+ ? Buffer.from(m[2], 'base64').toString('utf8')
96
+ : decodeURIComponent(m[2]);
97
+ }
98
+
99
+ export class SourceResolver {
100
+ /**
101
+ * @param {object} options
102
+ * @param {string} options.root absolute path of the frontend project root
103
+ * @param {Map<string, {url: string, sourceMapURL?: string}>} options.scripts by scriptId
104
+ * @param {(scriptId: string) => Promise<string>} options.getSource Debugger.getScriptSource
105
+ * @param {(url: string) => Promise<string>} [options.fetchText]
106
+ */
107
+ constructor({ root, scripts, getSource, fetchText }) {
108
+ this.root = root;
109
+ this.scripts = scripts;
110
+ this.getSource = getSource;
111
+ this.fetchText = fetchText || (async url => (await fetch(url)).text());
112
+ this.strategies = new Map(); // scriptId -> {kind, ...}
113
+ this.cache = new Map();
114
+ this.kinds = new Set();
115
+ }
116
+
117
+ /** Load maps / marker indexes for every script that is about to be resolved. */
118
+ async prepare(scriptIds) {
119
+ await Promise.all([...new Set(scriptIds)].map(id => this.prepareOne(id)));
120
+ }
121
+
122
+ async prepareOne(scriptId) {
123
+ if (this.strategies.has(scriptId)) return;
124
+ const script = this.scripts.get(scriptId);
125
+ if (!script || !script.url) {
126
+ this.strategies.set(scriptId, { kind: 'none' });
127
+ return;
128
+ }
129
+ const { url, sourceMapURL } = script;
130
+
131
+ if (/^(?:webpack|webpack-internal):/.test(url)) {
132
+ this.strategies.set(scriptId, { kind: 'url' });
133
+ return;
134
+ }
135
+ if (sourceMapURL) {
136
+ try {
137
+ const text = sourceMapURL.startsWith('data:')
138
+ ? decodeDataUrl(sourceMapURL)
139
+ : await this.fetchText(new URL(sourceMapURL, url).href);
140
+ // Resolving against the script URL turns 'heavy.js' or '../src/x.ts' into a full URL.
141
+ this.strategies.set(scriptId, {
142
+ kind: 'map',
143
+ map: new TraceMap(JSON.parse(text), url),
144
+ });
145
+ this.kinds.add('sourcemap');
146
+ return;
147
+ } catch {
148
+ /* unreachable or malformed map: fall through */
149
+ }
150
+ }
151
+ try {
152
+ const source = await this.getSource(scriptId);
153
+ const markers = indexWebpackModules(source);
154
+ if (markers.length) {
155
+ this.strategies.set(scriptId, { kind: 'markers', markers });
156
+ this.kinds.add('webpack-markers');
157
+ return;
158
+ }
159
+ } catch {
160
+ /* script gone */
161
+ }
162
+ this.strategies.set(scriptId, { kind: 'url' });
163
+ this.kinds.add('urls');
164
+ }
165
+
166
+ /**
167
+ * @returns {{file: string, vendor: boolean, pkg: string|null, line: number|null, name: string|null, exact: boolean}}
168
+ */
169
+ resolve(scriptId, url, line, column, functionName) {
170
+ const key = `${scriptId}:${line}:${column}`;
171
+ let hit = this.cache.get(key);
172
+ if (hit) return hit;
173
+ hit = this.resolveUncached(scriptId, url, line, column, functionName);
174
+ this.cache.set(key, hit);
175
+ return hit;
176
+ }
177
+
178
+ resolveUncached(scriptId, url, line, column) {
179
+ const strategy = this.strategies.get(scriptId) || { kind: 'url' };
180
+ if (strategy.kind === 'map' && line >= 0) {
181
+ const pos = originalPositionFor(strategy.map, {
182
+ line: line + 1,
183
+ column: Math.max(column, 0),
184
+ });
185
+ if (pos.source) {
186
+ return this.finish(
187
+ pathFromUrl(pos.source, this.root),
188
+ pos.line,
189
+ pos.name,
190
+ true,
191
+ );
192
+ }
193
+ }
194
+ if (strategy.kind === 'markers' && line >= 0) {
195
+ const id = moduleAt(strategy.markers, line);
196
+ if (id)
197
+ return this.finish(normalizePath(id, this.root), null, null, false);
198
+ }
199
+ if (!url) return this.finish('(native)', null, null, false);
200
+ if (/^(?:pptr:|chrome-extension:|extensions::|devtools:)/.test(url))
201
+ return this.finish('(injected)', null, null, false);
202
+ const p = pathFromUrl(url, this.root);
203
+ return this.finish(
204
+ looksLikeBundle(p) ? `bundle:${posix.basename(p)}` : p.replace(/^\//, ''),
205
+ null,
206
+ null,
207
+ false,
208
+ );
209
+ }
210
+
211
+ finish(file, line, name, exact) {
212
+ const pkg = packageOf(file);
213
+ return { file, vendor: Boolean(pkg), pkg, line, name, exact };
214
+ }
215
+ }
216
+
217
+ const MODULE_ID = /^(?:\.{1,2}\/|webpack\/)/;
218
+
219
+ /** [{line, id}] sorted by line (0-based) for every webpack module start marker. */
220
+ export function indexWebpackModules(source) {
221
+ const markers = [];
222
+ const lines = source.split('\n');
223
+ for (let i = 0; i < lines.length; i += 1) {
224
+ const l = lines[i];
225
+ const c = l.charCodeAt(0);
226
+ // markers start with '/*' ; banners with optional spaces then '!*'
227
+ const m = c === 47 ? MARKER.exec(l) : BANNER.exec(l);
228
+ // a module's quoted marker and its banner name the same module: keep the first
229
+ if (m && MODULE_ID.test(m[1]) && markers[markers.length - 1]?.id !== m[1])
230
+ markers.push({ line: i, id: m[1] });
231
+ }
232
+ return markers;
233
+ }
234
+
235
+ export function moduleAt(markers, line) {
236
+ let lo = 0;
237
+ let hi = markers.length - 1;
238
+ let found = -1;
239
+ while (lo <= hi) {
240
+ const mid = (lo + hi) >> 1;
241
+ if (markers[mid].line <= line) {
242
+ found = mid;
243
+ lo = mid + 1;
244
+ } else hi = mid - 1;
245
+ }
246
+ if (found === -1) return null;
247
+ const id = markers[found].id;
248
+ return id.startsWith('webpack/') ? null : id;
249
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Main-thread analysis of a Chrome trace: what kind of work filled each task,
3
+ * which tasks were long, and which layout/style work was forced by script.
4
+ * Timestamps are microseconds (trace clock) throughout.
5
+ */
6
+
7
+ export const LONG_TASK_US = 50_000;
8
+ export const THRASH_THRESHOLD = 3;
9
+
10
+ const SCRIPTING = new Set([
11
+ 'FunctionCall',
12
+ 'EvaluateScript',
13
+ 'v8.evaluateModule',
14
+ 'v8.callFunction',
15
+ 'v8.run',
16
+ 'EventDispatch',
17
+ 'TimerFire',
18
+ 'FireAnimationFrame',
19
+ 'FireIdleCallback',
20
+ 'RunMicrotasks',
21
+ 'XHRReadyStateChange',
22
+ 'XHRLoad',
23
+ 'HandlePostMessage',
24
+ 'FireAnimationFrame',
25
+ ]);
26
+ const COMPILE = new Set([
27
+ 'v8.compile',
28
+ 'V8.CompileCode',
29
+ 'CompileScript',
30
+ 'v8.produceCache',
31
+ 'v8.compileModule',
32
+ 'V8.CompileScript',
33
+ ]);
34
+ const STYLE = new Set(['UpdateLayoutTree', 'RecalculateStyles']);
35
+ const LAYOUT = new Set(['Layout']);
36
+ const PAINT = new Set([
37
+ 'Paint',
38
+ 'PrePaint',
39
+ 'Layerize',
40
+ 'UpdateLayer',
41
+ 'CompositeLayers',
42
+ 'Commit',
43
+ 'HitTest',
44
+ ]);
45
+ const PARSE = new Set(['ParseHTML', 'ParseAuthorStyleSheet']);
46
+
47
+ export const CATEGORIES = [
48
+ 'scripting',
49
+ 'compile',
50
+ 'style',
51
+ 'layout',
52
+ 'paint',
53
+ 'gc',
54
+ 'parse',
55
+ 'other',
56
+ ];
57
+
58
+ export function categoryOf(name) {
59
+ if (SCRIPTING.has(name)) return 'scripting';
60
+ if (COMPILE.has(name)) return 'compile';
61
+ if (STYLE.has(name)) return 'style';
62
+ if (LAYOUT.has(name)) return 'layout';
63
+ if (PAINT.has(name)) return 'paint';
64
+ if (PARSE.has(name)) return 'parse';
65
+ if (
66
+ /GC|Scavenge|MajorGC|MinorGC/.test(name) &&
67
+ !/^(?:RunTask|ThreadControllerImpl)/.test(name)
68
+ )
69
+ return 'gc';
70
+ return 'other';
71
+ }
72
+
73
+ const ENTRY_EVENTS = new Set([
74
+ 'EventDispatch',
75
+ 'TimerFire',
76
+ 'FireAnimationFrame',
77
+ 'FireIdleCallback',
78
+ 'XHRReadyStateChange',
79
+ 'XHRLoad',
80
+ 'EvaluateScript',
81
+ 'v8.evaluateModule',
82
+ 'RunMicrotasks',
83
+ 'HandlePostMessage',
84
+ 'FunctionCall',
85
+ 'ParseHTML',
86
+ ]);
87
+ const SCRIPT_PARENTS = new Set([...SCRIPTING]);
88
+
89
+ /** Picks the renderer main thread: the CrRendererMain with the most task time. */
90
+ export function findMainThread(events) {
91
+ const names = new Map();
92
+ for (const e of events) {
93
+ if (
94
+ e.ph === 'M' &&
95
+ e.name === 'thread_name' &&
96
+ e.args?.name === 'CrRendererMain'
97
+ ) {
98
+ names.set(`${e.pid}:${e.tid}`, { pid: e.pid, tid: e.tid });
99
+ }
100
+ }
101
+ const busy = new Map();
102
+ for (const e of events) {
103
+ if (e.name !== 'RunTask' || e.ph !== 'X') continue;
104
+ const k = `${e.pid}:${e.tid}`;
105
+ if (names.has(k)) busy.set(k, (busy.get(k) || 0) + (e.dur || 0));
106
+ }
107
+ let best = null;
108
+ let bestBusy = -1;
109
+ for (const [k, v] of busy) {
110
+ if (v > bestBusy) {
111
+ best = names.get(k);
112
+ bestBusy = v;
113
+ }
114
+ }
115
+ return best || [...names.values()][0] || null;
116
+ }
117
+
118
+ /** Complete ('X') events of one thread, with B/E pairs folded in, sorted for nesting. */
119
+ function threadEvents(events, pid, tid) {
120
+ const out = [];
121
+ const open = [];
122
+ for (const e of events) {
123
+ if (e.pid !== pid || e.tid !== tid) continue;
124
+ if (e.ph === 'X') out.push(e);
125
+ else if (e.ph === 'B') open.push(e);
126
+ else if (e.ph === 'E') {
127
+ const b = open.pop();
128
+ if (b) out.push({ ...b, ph: 'X', dur: e.ts - b.ts });
129
+ }
130
+ }
131
+ out.sort((a, b) => a.ts - b.ts || (b.dur || 0) - (a.dur || 0));
132
+ return out;
133
+ }
134
+
135
+ function buildTree(list) {
136
+ const roots = [];
137
+ const stack = [];
138
+ for (const e of list) {
139
+ const node = {
140
+ e,
141
+ start: e.ts,
142
+ end: e.ts + (e.dur || 0),
143
+ children: [],
144
+ parent: null,
145
+ self: e.dur || 0,
146
+ };
147
+ while (stack.length && stack[stack.length - 1].end <= node.start)
148
+ stack.pop();
149
+ const parent = stack[stack.length - 1] || null;
150
+ node.parent = parent;
151
+ if (parent) {
152
+ parent.children.push(node);
153
+ parent.self -= node.end - node.start;
154
+ } else roots.push(node);
155
+ stack.push(node);
156
+ }
157
+ return roots;
158
+ }
159
+
160
+ function* descendants(node) {
161
+ const stack = [...node.children];
162
+ while (stack.length) {
163
+ const n = stack.pop();
164
+ yield n;
165
+ for (const c of n.children) stack.push(c);
166
+ }
167
+ }
168
+
169
+ function frameOf(cf) {
170
+ return {
171
+ functionName: cf.functionName || '(anonymous)',
172
+ url: cf.url || '',
173
+ scriptId: cf.scriptId,
174
+ lineNumber: cf.lineNumber,
175
+ columnNumber: cf.columnNumber,
176
+ };
177
+ }
178
+
179
+ function describeTrigger(node) {
180
+ // The outermost entry point (event, timer, script…) with the longest duration.
181
+ let best = null;
182
+ const stack = [...node.children].reverse();
183
+ while (stack.length) {
184
+ const n = stack.pop();
185
+ if (
186
+ ENTRY_EVENTS.has(n.e.name) &&
187
+ (!best || n.end - n.start > best.end - best.start)
188
+ )
189
+ best = n;
190
+ for (let i = n.children.length - 1; i >= 0; i -= 1)
191
+ stack.push(n.children[i]);
192
+ }
193
+ if (!best) return { type: 'task', detail: '' };
194
+ const data = best.e.args?.data || {};
195
+ switch (best.e.name) {
196
+ case 'EventDispatch':
197
+ return {
198
+ type: `${data.type || 'event'} handler`,
199
+ detail: data.type || '',
200
+ };
201
+ case 'TimerFire':
202
+ return { type: 'timer', detail: `timer #${data.timerId ?? ''}` };
203
+ case 'FireAnimationFrame':
204
+ return { type: 'requestAnimationFrame', detail: '' };
205
+ case 'EvaluateScript':
206
+ case 'v8.evaluateModule':
207
+ return { type: 'script evaluation', detail: data.url || '' };
208
+ case 'FunctionCall':
209
+ return {
210
+ type: 'function call',
211
+ detail: data.functionName
212
+ ? `${data.functionName} (${data.url || ''}:${data.lineNumber ?? ''})`
213
+ : data.url || '',
214
+ };
215
+ case 'ParseHTML':
216
+ return { type: 'HTML parsing', detail: '' };
217
+ default:
218
+ return { type: best.e.name, detail: data.url || '' };
219
+ }
220
+ }
221
+
222
+ /**
223
+ * @param {object[]} events raw trace events
224
+ * @param {{pid: number, tid: number}} thread
225
+ */
226
+ export function analyzeTrace(events, thread) {
227
+ const list = threadEvents(events, thread.pid, thread.tid);
228
+ const roots = buildTree(list);
229
+
230
+ const totals = Object.fromEntries(CATEGORIES.map(c => [c, 0]));
231
+ const counts = { layout: 0, style: 0, paint: 0, gc: 0, majorGc: 0 };
232
+ let maxLayoutObjects = 0;
233
+ const tasks = [];
234
+ const forced = [];
235
+
236
+ const visit = (node, forcedAncestor) => {
237
+ const name = node.e.name;
238
+ const cat = categoryOf(name);
239
+ const isTask = name === 'RunTask';
240
+ if (!isTask || node.children.length === 0)
241
+ totals[isTask ? 'other' : cat] += Math.max(node.self, 0);
242
+ if (cat === 'layout') {
243
+ counts.layout += 1;
244
+ maxLayoutObjects = Math.max(
245
+ maxLayoutObjects,
246
+ node.e.args?.beginData?.totalObjects || 0,
247
+ );
248
+ } else if (cat === 'style') counts.style += 1;
249
+ else if (cat === 'paint' && name === 'Paint') counts.paint += 1;
250
+ else if (cat === 'gc') {
251
+ counts.gc += 1;
252
+ if (/Major/.test(name)) counts.majorGc += 1;
253
+ }
254
+ let scripted = forcedAncestor;
255
+ if (SCRIPT_PARENTS.has(name)) scripted = node;
256
+ if ((cat === 'layout' || cat === 'style') && forcedAncestor) {
257
+ const stack =
258
+ node.e.args?.beginData?.stackTrace || node.e.args?.data?.stackTrace;
259
+ forced.push({
260
+ kind: cat,
261
+ ts: node.start,
262
+ durUs: node.end - node.start,
263
+ stack: stack ? stack.map(frameOf) : null,
264
+ scriptParent: forcedAncestor.e.name,
265
+ dirtyObjects: node.e.args?.beginData?.dirtyObjects ?? null,
266
+ totalObjects: node.e.args?.beginData?.totalObjects ?? null,
267
+ });
268
+ }
269
+ for (const c of node.children) visit(c, scripted);
270
+ };
271
+
272
+ for (const r of roots) {
273
+ if (r.e.name === 'RunTask') {
274
+ const breakdown = Object.fromEntries(CATEGORIES.map(c => [c, 0]));
275
+ const before = forced.length;
276
+ breakdown.other += Math.max(r.self, 0);
277
+ for (const d of descendants(r))
278
+ breakdown[categoryOf(d.e.name)] += Math.max(d.self, 0);
279
+ visit(r, null);
280
+ const taskForced = forced.slice(before);
281
+ tasks.push({
282
+ ts: r.start,
283
+ durUs: r.end - r.start,
284
+ breakdown,
285
+ trigger: describeTrigger(r),
286
+ forcedCount: taskForced.length,
287
+ thrash:
288
+ taskForced.filter(f => f.kind === 'layout').length >=
289
+ THRASH_THRESHOLD,
290
+ });
291
+ } else {
292
+ visit(r, null);
293
+ }
294
+ }
295
+
296
+ // RunTask self time is scheduler overhead, not work: fold it into 'other'.
297
+ const busyUs = tasks.reduce((s, t) => s + t.durUs, 0);
298
+ const first = list.length ? list[0].ts : 0;
299
+ const last = list.reduce((m, e) => Math.max(m, e.ts + (e.dur || 0)), 0);
300
+
301
+ return {
302
+ tasks,
303
+ longTasks: tasks
304
+ .filter(t => t.durUs >= LONG_TASK_US)
305
+ .sort((a, b) => b.durUs - a.durUs),
306
+ forced,
307
+ totals,
308
+ counts,
309
+ maxLayoutObjects,
310
+ busyUs,
311
+ firstTs: first,
312
+ lastTs: last,
313
+ };
314
+ }