@progen-labs/spectra 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-D0Lxrmdt.js +46 -0
- package/dist/assets/index-Da3Le5Px.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +76 -4
- package/server/analysis/cpu.js +369 -0
- package/server/analysis/findings.js +570 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +775 -0
- package/server/analysis/sources.js +249 -0
- package/server/analysis/trace.js +314 -0
- package/server/api.js +292 -0
- package/server/chrome.js +81 -0
- package/server/cli.js +94 -0
- package/server/config.js +59 -0
- package/server/dashboard.js +154 -0
- package/server/explore/boot.js +150 -0
- package/server/explore/classify.js +169 -0
- package/server/explore/discover.js +162 -0
- package/server/explore/evaluate-cli.js +101 -0
- package/server/explore/evaluate.js +165 -0
- package/server/explore/paths.js +105 -0
- package/server/explore/runner.js +94 -0
- package/server/explore/test-explore.js +139 -0
- package/server/explore/test-paths.js +75 -0
- package/server/framework.js +67 -0
- package/server/git.js +59 -0
- package/server/pdf/render.js +60 -0
- package/server/pdf/template.js +419 -0
- package/server/plugin.js +19 -0
- package/server/probe.browser.js +828 -0
- package/server/recorder.js +610 -0
- package/server/signin.js +27 -0
- package/server/test-analysis.js +424 -0
- package/server/test-compare.js +473 -0
- package/server/test-sources.js +136 -0
- package/shared/compare.js +977 -0
- package/shared/snapshot.js +77 -0
- package/src/App.jsx +361 -0
- package/src/api.js +51 -0
- package/src/components/AppearanceMenu.jsx +76 -0
- package/src/components/CodeRef.jsx +48 -0
- package/src/components/ComparePanel.jsx +143 -0
- package/src/components/CompareResults.jsx +473 -0
- package/src/components/ControlBar.jsx +336 -0
- package/src/components/CulpritSphere.jsx +218 -0
- package/src/components/Culprits.jsx +120 -0
- package/src/components/CulpritsHero.jsx +109 -0
- package/src/components/EvaluationPanel.jsx +160 -0
- package/src/components/FilePanel.jsx +225 -0
- package/src/components/FindingsList.jsx +381 -0
- package/src/components/HeatTrees.jsx +222 -0
- package/src/components/Icons.jsx +58 -0
- package/src/components/LongTasks.jsx +199 -0
- package/src/components/NetworkPanel.jsx +98 -0
- package/src/components/PageMap.jsx +329 -0
- package/src/components/Popover.jsx +51 -0
- package/src/components/ReactPanel.jsx +206 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/SpectraCulprits.jsx +39 -0
- package/src/components/TimeBreakdown.jsx +45 -0
- package/src/components/TraceButton.jsx +76 -0
- package/src/components/TreeView.jsx +173 -0
- package/src/components/Vitals.jsx +163 -0
- package/src/culprits.js +127 -0
- package/src/download.js +18 -0
- package/src/format.js +26 -0
- package/src/heat.js +177 -0
- package/src/highlight.js +45 -0
- package/src/hooks/useCompare.js +187 -0
- package/src/hooks/useSession.js +73 -0
- package/src/layout.js +55 -0
- package/src/main.jsx +15 -0
- package/src/styles.css +2478 -0
- package/src/theme.js +75 -0
- package/src/themes.css +286 -0
- package/vite.config.js +8 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drives Chrome through the DevTools Protocol:
|
|
3
|
+
*
|
|
4
|
+
* Tracing main-thread timeline + V8 CPU samples (one clock)
|
|
5
|
+
* Debugger script registry (ids -> urls, source maps, sources)
|
|
6
|
+
* Runtime the in-page probe, function locations of components
|
|
7
|
+
* Performance/Memory heap, DOM nodes, listeners, layout counts
|
|
8
|
+
* Emulation CPU throttling
|
|
9
|
+
* Page injection before load, reload
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { buildReport } from './analysis/report.js';
|
|
16
|
+
import { SourceResolver } from './analysis/sources.js';
|
|
17
|
+
import { attachChrome, launchChrome } from './chrome.js';
|
|
18
|
+
import { FRONTEND_ROOT, REPORTS_DIR, SOURCE_ROOTS, FRAMEWORK } from './config.js';
|
|
19
|
+
import { SourceIndex } from './analysis/locate.js';
|
|
20
|
+
import { gitInfo } from './git.js';
|
|
21
|
+
import { buildSignInUrl, looksSignedOut } from './signin.js';
|
|
22
|
+
|
|
23
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const PROBE_SOURCE = fs.readFileSync(
|
|
25
|
+
path.join(here, 'probe.browser.js'),
|
|
26
|
+
'utf8',
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const TRACE_CATEGORIES = [
|
|
30
|
+
'-*',
|
|
31
|
+
'devtools.timeline',
|
|
32
|
+
'v8.execute',
|
|
33
|
+
'disabled-by-default-devtools.timeline',
|
|
34
|
+
'disabled-by-default-devtools.timeline.frame',
|
|
35
|
+
'disabled-by-default-devtools.timeline.stack',
|
|
36
|
+
'disabled-by-default-v8.cpu_profiler',
|
|
37
|
+
'toplevel',
|
|
38
|
+
'blink.user_timing',
|
|
39
|
+
'latencyInfo',
|
|
40
|
+
].join(',');
|
|
41
|
+
|
|
42
|
+
const MAX_RECORDING_MS = 120_000;
|
|
43
|
+
|
|
44
|
+
async function readStream(client, handle) {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
for (;;) {
|
|
47
|
+
const { data, base64Encoded, eof } = await client.send('IO.read', {
|
|
48
|
+
handle,
|
|
49
|
+
size: 1 << 20,
|
|
50
|
+
});
|
|
51
|
+
chunks.push(
|
|
52
|
+
base64Encoded ? Buffer.from(data, 'base64').toString('utf8') : data,
|
|
53
|
+
);
|
|
54
|
+
if (eof) break;
|
|
55
|
+
}
|
|
56
|
+
await client.send('IO.close', { handle });
|
|
57
|
+
return chunks.join('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class PerfSession extends EventEmitter {
|
|
61
|
+
constructor() {
|
|
62
|
+
super();
|
|
63
|
+
this.browser = null;
|
|
64
|
+
this.launched = false;
|
|
65
|
+
this.page = null;
|
|
66
|
+
this.client = null;
|
|
67
|
+
this.scripts = new Map();
|
|
68
|
+
this.pageIds = new WeakMap();
|
|
69
|
+
this.nextPageId = 1;
|
|
70
|
+
this.injected = [];
|
|
71
|
+
this.state = 'disconnected';
|
|
72
|
+
this.recording = null;
|
|
73
|
+
this.lastError = '';
|
|
74
|
+
this.lastTrace = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setState(state, extra = {}) {
|
|
78
|
+
this.state = state;
|
|
79
|
+
this.emit('state', { state, ...extra });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ------------------------------------------------------------- connection
|
|
83
|
+
|
|
84
|
+
async connect({
|
|
85
|
+
mode = 'launch',
|
|
86
|
+
url,
|
|
87
|
+
browserURL,
|
|
88
|
+
headless = false,
|
|
89
|
+
signIn,
|
|
90
|
+
}) {
|
|
91
|
+
if (this.browser) await this.disconnect();
|
|
92
|
+
this.browser =
|
|
93
|
+
mode === 'attach'
|
|
94
|
+
? await attachChrome(browserURL || 'http://127.0.0.1:9222')
|
|
95
|
+
: await launchChrome({ headless });
|
|
96
|
+
this.launched = mode !== 'attach';
|
|
97
|
+
this.browser.on('disconnected', () => {
|
|
98
|
+
this.browser = null;
|
|
99
|
+
this.page = null;
|
|
100
|
+
this.client = null;
|
|
101
|
+
this.recording = null;
|
|
102
|
+
this.setState('disconnected');
|
|
103
|
+
});
|
|
104
|
+
const pages = await this.browser.pages();
|
|
105
|
+
const page =
|
|
106
|
+
pages.find(p => !/^devtools:|^chrome:/.test(p.url())) ||
|
|
107
|
+
pages[0] ||
|
|
108
|
+
(await this.browser.newPage());
|
|
109
|
+
await this.attachPage(page);
|
|
110
|
+
if (url) await this.navigate(url, signIn);
|
|
111
|
+
this.setState('idle');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async disconnect() {
|
|
115
|
+
const browser = this.browser;
|
|
116
|
+
this.browser = null;
|
|
117
|
+
this.page = null;
|
|
118
|
+
this.client = null;
|
|
119
|
+
if (browser) {
|
|
120
|
+
if (this.launched) await browser.close().catch(() => {});
|
|
121
|
+
else await browser.disconnect();
|
|
122
|
+
}
|
|
123
|
+
this.setState('disconnected');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async listPages() {
|
|
127
|
+
if (!this.browser) return [];
|
|
128
|
+
const pages = await this.browser.pages();
|
|
129
|
+
return Promise.all(
|
|
130
|
+
pages
|
|
131
|
+
.filter(p => !/^devtools:/.test(p.url()))
|
|
132
|
+
.map(async p => {
|
|
133
|
+
if (!this.pageIds.has(p)) this.pageIds.set(p, this.nextPageId++);
|
|
134
|
+
return {
|
|
135
|
+
id: this.pageIds.get(p),
|
|
136
|
+
url: p.url(),
|
|
137
|
+
title: await p.title().catch(() => ''),
|
|
138
|
+
selected: p === this.page,
|
|
139
|
+
};
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async selectPage(id) {
|
|
145
|
+
const pages = await this.browser.pages();
|
|
146
|
+
const page = pages.find(p => this.pageIds.get(p) === id);
|
|
147
|
+
if (!page) throw new Error('That tab no longer exists.');
|
|
148
|
+
await this.attachPage(page);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async attachPage(page) {
|
|
152
|
+
if (this.client) await this.client.detach().catch(() => {});
|
|
153
|
+
this.page = page;
|
|
154
|
+
this.scripts.clear();
|
|
155
|
+
this.injected = [];
|
|
156
|
+
const client = await page.createCDPSession();
|
|
157
|
+
this.client = client;
|
|
158
|
+
client.on('Debugger.scriptParsed', e =>
|
|
159
|
+
this.scripts.set(e.scriptId, {
|
|
160
|
+
url: e.url,
|
|
161
|
+
sourceMapURL: e.sourceMapURL,
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
client.on('Debugger.globalObjectCleared', () => this.scripts.clear());
|
|
165
|
+
await client.send('Debugger.enable');
|
|
166
|
+
await client.send('Page.enable');
|
|
167
|
+
await client.send('Performance.enable');
|
|
168
|
+
// Inject a small runtime hint so the in-page probe can adapt to the detected
|
|
169
|
+
// framework/bundler (react/vue/svelte/next) before the probe runs.
|
|
170
|
+
try {
|
|
171
|
+
await this.inject(`window.__SPECTRA_FRAMEWORK__ = ${JSON.stringify(
|
|
172
|
+
FRAMEWORK,
|
|
173
|
+
)};`);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
// ignore injection errors
|
|
176
|
+
}
|
|
177
|
+
await this.inject(PROBE_SOURCE);
|
|
178
|
+
this.navigation = null;
|
|
179
|
+
page.on('response', res => {
|
|
180
|
+
// Redirects (3xx) are intermediate; the final document decides if the page loaded.
|
|
181
|
+
const status = res.status();
|
|
182
|
+
if (
|
|
183
|
+
res.request().isNavigationRequest() &&
|
|
184
|
+
res.frame() === page.mainFrame() &&
|
|
185
|
+
(status < 300 || status >= 400)
|
|
186
|
+
) {
|
|
187
|
+
this.navigation = { url: res.url(), status };
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
page.on('close', () => {
|
|
191
|
+
if (this.page === page) {
|
|
192
|
+
this.page = null;
|
|
193
|
+
this.client = null;
|
|
194
|
+
this.emit('state', { state: this.state });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Runs `source` before any script of every future document, and in the current one. */
|
|
200
|
+
async inject(source, { now = true } = {}) {
|
|
201
|
+
const { identifier } = await this.client.send(
|
|
202
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
203
|
+
{ source },
|
|
204
|
+
);
|
|
205
|
+
this.injected.push(identifier);
|
|
206
|
+
if (now)
|
|
207
|
+
await this.client
|
|
208
|
+
.send('Runtime.evaluate', { expression: source })
|
|
209
|
+
.catch(() => {});
|
|
210
|
+
return identifier;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
requirePage() {
|
|
214
|
+
if (!this.client)
|
|
215
|
+
throw new Error('No tab is connected. Connect to Chrome first.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Opens `url`. If that fails or lands on a login screen (the browser profile
|
|
220
|
+
* starts signed out) and a sign-in URL template is given, signs in through it
|
|
221
|
+
* and lands on `url` afterwards.
|
|
222
|
+
*/
|
|
223
|
+
async navigate(url, signIn) {
|
|
224
|
+
this.requirePage();
|
|
225
|
+
const go = target =>
|
|
226
|
+
this.page.goto(target, {
|
|
227
|
+
waitUntil: 'domcontentloaded',
|
|
228
|
+
timeout: 60_000,
|
|
229
|
+
});
|
|
230
|
+
this.navigation = null;
|
|
231
|
+
await go(url);
|
|
232
|
+
const signInUrl = buildSignInUrl(signIn, url);
|
|
233
|
+
if (signInUrl && looksSignedOut(this.navigation, this.page.url())) {
|
|
234
|
+
this.navigation = null;
|
|
235
|
+
await go(signInUrl);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async reload() {
|
|
240
|
+
this.requirePage();
|
|
241
|
+
await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async probeInfo() {
|
|
245
|
+
if (!this.client || this.state !== 'idle') return null;
|
|
246
|
+
try {
|
|
247
|
+
const { result } = await this.client.send('Runtime.evaluate', {
|
|
248
|
+
expression: 'window.__PERF_PROBE__ ? __PERF_PROBE__.info() : null',
|
|
249
|
+
returnByValue: true,
|
|
250
|
+
});
|
|
251
|
+
return result.value || null;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async status() {
|
|
258
|
+
return {
|
|
259
|
+
state: this.state,
|
|
260
|
+
connected: Boolean(this.browser),
|
|
261
|
+
launched: this.launched,
|
|
262
|
+
pageUrl: this.page?.url() || '',
|
|
263
|
+
pages: await this.listPages(),
|
|
264
|
+
probe: await this.probeInfo(),
|
|
265
|
+
navigation: this.navigation,
|
|
266
|
+
recording: this.recording && {
|
|
267
|
+
mode: this.recording.mode,
|
|
268
|
+
startedAt: this.recording.startedAt,
|
|
269
|
+
cpuThrottle: this.recording.cpuThrottle,
|
|
270
|
+
},
|
|
271
|
+
error: this.lastError,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// -------------------------------------------------------------- recording
|
|
276
|
+
|
|
277
|
+
async metrics() {
|
|
278
|
+
const { metrics } = await this.client.send('Performance.getMetrics');
|
|
279
|
+
return Object.fromEntries(metrics.map(m => [m.name, m.value]));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async start({
|
|
283
|
+
mode = 'live',
|
|
284
|
+
cpuThrottle = 1,
|
|
285
|
+
settleMs = 3000,
|
|
286
|
+
saveTrace = false,
|
|
287
|
+
ignoreCache = true,
|
|
288
|
+
} = {}) {
|
|
289
|
+
this.requirePage();
|
|
290
|
+
if (this.state !== 'idle')
|
|
291
|
+
throw new Error(`Cannot start while ${this.state}.`);
|
|
292
|
+
this.lastError = '';
|
|
293
|
+
const client = this.client;
|
|
294
|
+
const rec = {
|
|
295
|
+
mode,
|
|
296
|
+
cpuThrottle,
|
|
297
|
+
saveTrace,
|
|
298
|
+
settleMs,
|
|
299
|
+
startedAt: Date.now(),
|
|
300
|
+
autostartId: null,
|
|
301
|
+
timer: null,
|
|
302
|
+
loaded: null,
|
|
303
|
+
};
|
|
304
|
+
this.recording = rec;
|
|
305
|
+
|
|
306
|
+
if (cpuThrottle > 1)
|
|
307
|
+
await client.send('Emulation.setCPUThrottlingRate', {
|
|
308
|
+
rate: cpuThrottle,
|
|
309
|
+
});
|
|
310
|
+
if (mode === 'load') {
|
|
311
|
+
// Registered after the probe script, so it runs after it on every new document.
|
|
312
|
+
rec.autostartId = await this.inject(
|
|
313
|
+
'window.__PERF_PROBE__ && window.__PERF_PROBE__.start();',
|
|
314
|
+
{ now: false },
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
const info = await this.probeInfo();
|
|
318
|
+
if (!info) await this.inject(PROBE_SOURCE);
|
|
319
|
+
await client.send('Runtime.evaluate', {
|
|
320
|
+
expression: '__PERF_PROBE__.start()',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
rec.metricsBefore = await this.metrics();
|
|
324
|
+
await client.send('Tracing.start', {
|
|
325
|
+
categories: TRACE_CATEGORIES,
|
|
326
|
+
transferMode: 'ReturnAsStream',
|
|
327
|
+
streamFormat: 'json',
|
|
328
|
+
});
|
|
329
|
+
this.tracingActive = true;
|
|
330
|
+
rec.startedAt = Date.now();
|
|
331
|
+
this.setState('recording', { mode });
|
|
332
|
+
|
|
333
|
+
rec.timer = setTimeout(
|
|
334
|
+
() => this.stop().catch(err => this.fail(err)),
|
|
335
|
+
MAX_RECORDING_MS,
|
|
336
|
+
); // hard cap: traces grow fast
|
|
337
|
+
if (mode === 'load') {
|
|
338
|
+
rec.loaded = new Promise(resolve =>
|
|
339
|
+
client.once('Page.loadEventFired', resolve),
|
|
340
|
+
);
|
|
341
|
+
await client.send('Page.reload', { ignoreCache });
|
|
342
|
+
rec.loaded.then(() => {
|
|
343
|
+
if (this.state !== 'recording') return;
|
|
344
|
+
clearTimeout(rec.timer);
|
|
345
|
+
rec.timer = setTimeout(
|
|
346
|
+
() => this.stop().catch(err => this.fail(err)),
|
|
347
|
+
settleMs,
|
|
348
|
+
); // let post-load work (data fetches, renders) finish
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
fail(err) {
|
|
354
|
+
this.lastError = err.message;
|
|
355
|
+
this.recording = null;
|
|
356
|
+
// A stop() that throws before Tracing.end leaves CDP tracing active, which
|
|
357
|
+
// would make the next start() fail with "Tracing has already been started".
|
|
358
|
+
// Tear it down so one bad recording can't poison the whole session.
|
|
359
|
+
if (this.tracingActive && this.client) {
|
|
360
|
+
this.tracingActive = false;
|
|
361
|
+
this.client.send('Tracing.end').catch(() => {});
|
|
362
|
+
}
|
|
363
|
+
this.setState(this.browser ? 'idle' : 'disconnected', {
|
|
364
|
+
error: err.message,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async stop() {
|
|
369
|
+
const rec = this.recording;
|
|
370
|
+
if (!rec || this.state !== 'recording') throw new Error('Not recording.');
|
|
371
|
+
clearTimeout(rec.timer);
|
|
372
|
+
const client = this.client;
|
|
373
|
+
const page = this.page;
|
|
374
|
+
this.setState('analyzing');
|
|
375
|
+
try {
|
|
376
|
+
const durationMs = Date.now() - rec.startedAt;
|
|
377
|
+
const { result: snap } = await client.send('Runtime.evaluate', {
|
|
378
|
+
expression:
|
|
379
|
+
'window.__PERF_PROBE__ ? (__PERF_PROBE__.stop(), __PERF_PROBE__.snapshot()) : null',
|
|
380
|
+
returnByValue: true,
|
|
381
|
+
});
|
|
382
|
+
const probe = snap.value || null;
|
|
383
|
+
const metricsAfter = await this.metrics();
|
|
384
|
+
const dom = await client.send('Memory.getDOMCounters').catch(() => null);
|
|
385
|
+
|
|
386
|
+
const complete = new Promise(resolve =>
|
|
387
|
+
client.once('Tracing.tracingComplete', resolve),
|
|
388
|
+
);
|
|
389
|
+
await client.send('Tracing.end');
|
|
390
|
+
this.tracingActive = false;
|
|
391
|
+
const { stream } = await complete;
|
|
392
|
+
const traceText = await readStream(client, stream);
|
|
393
|
+
const parsed = JSON.parse(traceText);
|
|
394
|
+
const events = Array.isArray(parsed) ? parsed : parsed.traceEvents;
|
|
395
|
+
|
|
396
|
+
if (rec.cpuThrottle > 1)
|
|
397
|
+
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
|
398
|
+
if (rec.autostartId) {
|
|
399
|
+
await client
|
|
400
|
+
.send('Page.removeScriptToEvaluateOnNewDocument', {
|
|
401
|
+
identifier: rec.autostartId,
|
|
402
|
+
})
|
|
403
|
+
.catch(() => {});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const pageView = await this.capturePageView();
|
|
407
|
+
const locations = await this.componentLocations(probe);
|
|
408
|
+
const resolver = new SourceResolver({
|
|
409
|
+
root: FRONTEND_ROOT,
|
|
410
|
+
scripts: this.scripts,
|
|
411
|
+
getSource: async scriptId =>
|
|
412
|
+
(await client.send('Debugger.getScriptSource', { scriptId }))
|
|
413
|
+
.scriptSource,
|
|
414
|
+
});
|
|
415
|
+
const id =
|
|
416
|
+
new Date(rec.startedAt)
|
|
417
|
+
.toISOString()
|
|
418
|
+
.replace(/[-:]/g, '')
|
|
419
|
+
.replace(/\..+/, '') + `-${rec.mode}`;
|
|
420
|
+
const report = await buildReport({
|
|
421
|
+
events,
|
|
422
|
+
probe,
|
|
423
|
+
metrics: { before: rec.metricsBefore, after: metricsAfter, dom },
|
|
424
|
+
locations,
|
|
425
|
+
resolver,
|
|
426
|
+
sourceIndex: new SourceIndex(SOURCE_ROOTS),
|
|
427
|
+
meta: {
|
|
428
|
+
id,
|
|
429
|
+
createdAt: new Date(rec.startedAt).toISOString(),
|
|
430
|
+
url: page.url(),
|
|
431
|
+
title: await page.title().catch(() => ''),
|
|
432
|
+
mode: rec.mode,
|
|
433
|
+
durationMs,
|
|
434
|
+
cpuThrottle: rec.cpuThrottle,
|
|
435
|
+
userAgent: await this.browser.userAgent(),
|
|
436
|
+
git: gitInfo(FRONTEND_ROOT),
|
|
437
|
+
navStatus: this.navigation?.status ?? null,
|
|
438
|
+
navUrl: this.navigation?.url ?? '',
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
this.lastTrace = { id: report.id, text: traceText };
|
|
442
|
+
report.pageView = pageView
|
|
443
|
+
? { ...pageView.layout, hasImage: true }
|
|
444
|
+
: null;
|
|
445
|
+
this.saveReport(
|
|
446
|
+
report,
|
|
447
|
+
rec.saveTrace ? traceText : null,
|
|
448
|
+
pageView?.image,
|
|
449
|
+
);
|
|
450
|
+
this.recording = null;
|
|
451
|
+
this.setState('idle', { reportId: report.id });
|
|
452
|
+
this.emit('report', { id: report.id });
|
|
453
|
+
return report;
|
|
454
|
+
} catch (err) {
|
|
455
|
+
this.fail(err);
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* What the page looks like right now plus where each React component sits on
|
|
462
|
+
* it, so the report can point from a slow component to its place on screen.
|
|
463
|
+
* Best effort: any failure just means the report has no page map.
|
|
464
|
+
*/
|
|
465
|
+
async capturePageView() {
|
|
466
|
+
try {
|
|
467
|
+
const { result } = await this.client.send('Runtime.evaluate', {
|
|
468
|
+
expression:
|
|
469
|
+
'window.__PERF_PROBE__ && __PERF_PROBE__.layout ? __PERF_PROBE__.layout() : null',
|
|
470
|
+
returnByValue: true,
|
|
471
|
+
});
|
|
472
|
+
const layout = result.value;
|
|
473
|
+
if (!layout?.entries?.length) return null;
|
|
474
|
+
const shot = await this.client.send('Page.captureScreenshot', {
|
|
475
|
+
format: 'jpeg',
|
|
476
|
+
quality: 72,
|
|
477
|
+
clip: {
|
|
478
|
+
x: 0,
|
|
479
|
+
y: 0,
|
|
480
|
+
width: layout.width,
|
|
481
|
+
height: layout.height,
|
|
482
|
+
scale: 1,
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
return { layout, image: Buffer.from(shot.data, 'base64') };
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Function -> source location for each React component seen, via [[FunctionLocation]]. */
|
|
492
|
+
async componentLocations(probe) {
|
|
493
|
+
const out = new Map();
|
|
494
|
+
if (!probe?.components?.length) return out;
|
|
495
|
+
const wanted = new Set(
|
|
496
|
+
probe.components.filter(c => !c.source).map(c => c.id),
|
|
497
|
+
);
|
|
498
|
+
if (!wanted.size) return out;
|
|
499
|
+
try {
|
|
500
|
+
const list = await this.client.send('Runtime.evaluate', {
|
|
501
|
+
expression: '__PERF_PROBE__.typeFns',
|
|
502
|
+
returnByValue: false,
|
|
503
|
+
});
|
|
504
|
+
const props = await this.client.send('Runtime.getProperties', {
|
|
505
|
+
objectId: list.result.objectId,
|
|
506
|
+
ownProperties: true,
|
|
507
|
+
});
|
|
508
|
+
const fns = props.result.filter(
|
|
509
|
+
p =>
|
|
510
|
+
/^\d+$/.test(p.name) &&
|
|
511
|
+
wanted.has(Number(p.name)) &&
|
|
512
|
+
p.value?.objectId,
|
|
513
|
+
);
|
|
514
|
+
for (let i = 0; i < fns.length; i += 40) {
|
|
515
|
+
await Promise.all(
|
|
516
|
+
fns.slice(i, i + 40).map(async p => {
|
|
517
|
+
const r = await this.client.send('Runtime.getProperties', {
|
|
518
|
+
objectId: p.value.objectId,
|
|
519
|
+
ownProperties: false,
|
|
520
|
+
});
|
|
521
|
+
const loc = r.internalProperties?.find(
|
|
522
|
+
x => x.name === '[[FunctionLocation]]',
|
|
523
|
+
)?.value?.value;
|
|
524
|
+
if (loc) out.set(Number(p.name), loc);
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
} catch {
|
|
529
|
+
/* page navigated away: locations are optional */
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
saveReport(report, trace, pageImage) {
|
|
535
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
536
|
+
fs.writeFileSync(
|
|
537
|
+
path.join(REPORTS_DIR, `${report.id}.json`),
|
|
538
|
+
JSON.stringify(report),
|
|
539
|
+
);
|
|
540
|
+
if (trace) fs.writeFileSync(traceFile(report.id), trace);
|
|
541
|
+
if (pageImage) fs.writeFileSync(pageImageFile(report.id), pageImage);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Makes sure reports/<id>.trace.json exists so it can be loaded into Chrome
|
|
546
|
+
* DevTools. The latest recording's trace is kept in memory for this; older
|
|
547
|
+
* ones exist only if "save raw trace" was ticked.
|
|
548
|
+
*/
|
|
549
|
+
writeTrace(id) {
|
|
550
|
+
const file = traceFile(id);
|
|
551
|
+
if (!fs.existsSync(file)) {
|
|
552
|
+
if (this.lastTrace?.id !== id) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
'The raw trace of this recording was not kept. Only the most recent recording’s trace is held in memory (until you record again or stop the profiler); older ones need “save raw trace” ticked before recording.',
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
558
|
+
fs.writeFileSync(file, this.lastTrace.text);
|
|
559
|
+
}
|
|
560
|
+
return { path: file, size: fs.statSync(file).size };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function traceFile(id) {
|
|
565
|
+
return path.join(REPORTS_DIR, `${id}.trace.json`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function pageImageFile(id) {
|
|
569
|
+
return path.join(REPORTS_DIR, `${id}.page.jpg`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function listReports() {
|
|
573
|
+
if (!fs.existsSync(REPORTS_DIR)) return [];
|
|
574
|
+
return fs
|
|
575
|
+
.readdirSync(REPORTS_DIR)
|
|
576
|
+
.filter(f => f.endsWith('.json') && !f.endsWith('.trace.json'))
|
|
577
|
+
.map(f => {
|
|
578
|
+
try {
|
|
579
|
+
const r = JSON.parse(
|
|
580
|
+
fs.readFileSync(path.join(REPORTS_DIR, f), 'utf8'),
|
|
581
|
+
);
|
|
582
|
+
return {
|
|
583
|
+
id: r.id,
|
|
584
|
+
createdAt: r.createdAt,
|
|
585
|
+
url: r.meta.url,
|
|
586
|
+
mode: r.meta.mode,
|
|
587
|
+
durationMs: r.meta.durationMs,
|
|
588
|
+
hasTrace: fs.existsSync(traceFile(r.id)),
|
|
589
|
+
findings: r.findings.length,
|
|
590
|
+
critical: r.findings.filter(x => x.severity === 'critical').length,
|
|
591
|
+
};
|
|
592
|
+
} catch {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
.filter(Boolean)
|
|
597
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function readReport(id) {
|
|
601
|
+
if (!/^[\w-]+$/.test(id)) return null;
|
|
602
|
+
const file = path.join(REPORTS_DIR, `${id}.json`);
|
|
603
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function deleteReport(id) {
|
|
607
|
+
if (!/^[\w-]+$/.test(id)) return;
|
|
608
|
+
for (const suffix of ['.json', '.trace.json', '.page.jpg'])
|
|
609
|
+
fs.rmSync(path.join(REPORTS_DIR, `${id}${suffix}`), { force: true });
|
|
610
|
+
}
|
package/server/signin.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the URL that signs in and then lands on `pageUrl`.
|
|
3
|
+
*
|
|
4
|
+
* `template` is the app's login URL with a `{url}` placeholder for the page to
|
|
5
|
+
* return to, e.g. `http://localhost:3000/login/?redirect={url}`.
|
|
6
|
+
`{url}` becomes the URL-encoded path + query of the page (relative, which is
|
|
7
|
+
* what login redirects usually accept). The template must not contain secrets:
|
|
8
|
+
* it is remembered in the browser's localStorage.
|
|
9
|
+
*/
|
|
10
|
+
export function buildSignInUrl(template, pageUrl) {
|
|
11
|
+
if (!template) return null;
|
|
12
|
+
const u = new URL(pageUrl);
|
|
13
|
+
const relative = `${u.pathname}${u.search}${u.hash}`;
|
|
14
|
+
return template.includes('{url}')
|
|
15
|
+
? template.replaceAll('{url}', encodeURIComponent(relative))
|
|
16
|
+
: template;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** True when the document we ended up on is a failed load or a login screen. */
|
|
20
|
+
export function looksSignedOut(navigation, finalUrl) {
|
|
21
|
+
if (navigation && navigation.status >= 400) return true;
|
|
22
|
+
try {
|
|
23
|
+
return /^\/(?:login|auth)\b/i.test(new URL(finalUrl).pathname);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|