@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,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
+ }
@@ -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
+ }