@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,473 @@
1
+ // Named test-*.js (not *.test.js): the repo's jest testRegex covers tools/.
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+ import {
5
+ compareSides,
6
+ findingKey,
7
+ formatDelta,
8
+ formatPct,
9
+ formatValue,
10
+ judge,
11
+ median,
12
+ } from '../shared/compare.js';
13
+ import {
14
+ hashFromFileName,
15
+ snapshotFileName,
16
+ validateReport,
17
+ withCommit,
18
+ } from '../shared/snapshot.js';
19
+
20
+ /** A realistic-enough report; override any section. */
21
+ function makeReport(over = {}) {
22
+ const base = {
23
+ id: '20260930T100000-load',
24
+ createdAt: '2026-09-30T10:00:00.000Z',
25
+ meta: {
26
+ url: 'http://localhost:3000/pages/custom/579/?x=1',
27
+ mode: 'load',
28
+ durationMs: 10000,
29
+ cpuThrottle: 1,
30
+ navStatus: 200,
31
+ attribution: 'webpack-markers',
32
+ git: null,
33
+ label: '',
34
+ },
35
+ vitals: {
36
+ tbtMs: 1000,
37
+ longTaskCount: 10,
38
+ maxLongTaskMs: 400,
39
+ inpMs: 300,
40
+ lcpMs: 3000,
41
+ fcpMs: 1500,
42
+ cls: 0.05,
43
+ },
44
+ totals: {
45
+ activeMs: 4000,
46
+ scriptingMs: 3000,
47
+ layoutMs: 100,
48
+ styleMs: 200,
49
+ paintMs: 100,
50
+ gcMs: 100,
51
+ compileMs: 50,
52
+ layoutCount: 200,
53
+ styleRecalcCount: 300,
54
+ forcedLayoutCount: 100,
55
+ forcedStyleCount: 100,
56
+ forcedMs: 50,
57
+ },
58
+ memory: {
59
+ domNodes: 5000,
60
+ listeners: 800,
61
+ heapGrowthMb: 20,
62
+ heapUsedMb: 100,
63
+ },
64
+ network: { count: 120, totalEncoded: 5 * 1024 * 1024 },
65
+ react: {
66
+ detected: true,
67
+ hasTimings: true,
68
+ version: '17.0.2',
69
+ components: [],
70
+ commits: { count: 100, totalMs: 900, maxMs: 80 },
71
+ },
72
+ files: [],
73
+ findings: [],
74
+ };
75
+ const merged = { ...base, ...over };
76
+ for (const k of ['meta', 'vitals', 'totals', 'memory', 'network'])
77
+ merged[k] = { ...base[k], ...(over[k] || {}) };
78
+ merged.react = { ...base.react, ...(over.react || {}) };
79
+ return merged;
80
+ }
81
+ const side = (reports, git) => ({ runs: reports, git });
82
+ const metric = (cmp, id) => cmp.metrics.find(m => m.id === id);
83
+
84
+ test('median, formatting and the significance test', () => {
85
+ assert.equal(median([3, 1, 2]), 2);
86
+ assert.equal(median([1, 2, 3, 4]), 2.5);
87
+ assert.equal(median([]), null);
88
+ assert.equal(formatValue('ms', 1534), '1.53 s');
89
+ assert.equal(formatValue('ms', 42.34), '42.3 ms');
90
+ assert.equal(formatValue('bytes', 5 * 1024 * 1024), '5.00 MB');
91
+ assert.equal(formatPct(-0.424), '−42%');
92
+ assert.equal(formatPct(0.1), '+10%');
93
+ assert.equal(formatPct(null), 'new');
94
+ assert.equal(formatDelta('ms', -250), '−250 ms');
95
+ // below the absolute floor: 8 ms on a 20 ms metric is a 40 % change but still noise
96
+ assert.equal(judge([20], [28], { minAbs: 50, minRel: 0.1 }).verdict, 'same');
97
+ assert.equal(
98
+ judge([1000], [400], { minAbs: 50, minRel: 0.1 }).verdict,
99
+ 'improved',
100
+ );
101
+ assert.equal(
102
+ judge([1000], [1300], { minAbs: 50, minRel: 0.1 }).verdict,
103
+ 'degraded',
104
+ );
105
+ assert.equal(judge([null], [5], { minAbs: 1, minRel: 0.1 }).verdict, 'n/a');
106
+ });
107
+
108
+ test('run-to-run spread widens the band: a delta inside the noise is "same"', () => {
109
+ // before: 100 and 400 (spread 300, median 250); after: 150 -> delta -100 < 300
110
+ const noisy = judge([100, 400], [150], { minAbs: 50, minRel: 0.1 });
111
+ assert.equal(noisy.verdict, 'same');
112
+ assert.equal(noisy.threshold, 300);
113
+ // tight runs on both sides make the same delta significant
114
+ assert.equal(
115
+ judge([250, 252, 248], [150, 151, 149], { minAbs: 50, minRel: 0.1 })
116
+ .verdict,
117
+ 'improved',
118
+ );
119
+ });
120
+
121
+ test('metrics are classified improved / degraded / same and the verdict follows the key metrics', () => {
122
+ const before = makeReport();
123
+ const after = makeReport({
124
+ vitals: { tbtMs: 400, longTaskCount: 4 }, // key: improved
125
+ totals: { activeMs: 4010, gcMs: 100 }, // key: same
126
+ memory: { heapGrowthMb: 60 }, // key: degraded
127
+ });
128
+ const cmp = compareSides(side([before]), side([after]));
129
+ assert.equal(metric(cmp, 'tbt').verdict, 'improved');
130
+ assert.equal(metric(cmp, 'tbt').pct, -0.6);
131
+ assert.equal(metric(cmp, 'cpu').verdict, 'same');
132
+ assert.equal(metric(cmp, 'heapGrowth').verdict, 'degraded');
133
+ assert.equal(cmp.verdict, 'mixed');
134
+ assert.ok(cmp.counts.improved >= 2 && cmp.counts.degraded >= 1);
135
+ assert.match(
136
+ cmp.headlines.find(h => h.kind === 'improved').text,
137
+ /Total blocking time: 1\.00 s → 400 ms \(−60%\)/,
138
+ );
139
+ });
140
+
141
+ test('overall verdict: improved, regressed, neutral', () => {
142
+ const base = makeReport();
143
+ const better = makeReport({ vitals: { tbtMs: 300 } });
144
+ const worse = makeReport({ vitals: { tbtMs: 3000 } });
145
+ assert.equal(compareSides(side([base]), side([better])).verdict, 'improved');
146
+ assert.equal(compareSides(side([base]), side([worse])).verdict, 'regressed');
147
+ assert.equal(
148
+ compareSides(side([base]), side([makeReport()])).verdict,
149
+ 'neutral',
150
+ );
151
+ });
152
+
153
+ test('metrics unavailable on both sides (LCP in interaction mode) are n/a, not same', () => {
154
+ const live = makeReport({
155
+ meta: { mode: 'live' },
156
+ vitals: { lcpMs: null, fcpMs: null },
157
+ });
158
+ const cmp = compareSides(side([live]), side([live]));
159
+ assert.equal(metric(cmp, 'lcp').verdict, 'n/a');
160
+ assert.ok(cmp.counts.unavailable >= 2);
161
+ });
162
+
163
+ const component = (name, over = {}) => ({
164
+ name,
165
+ file: `src/${name}.tsx`,
166
+ line: 3,
167
+ renders: 100,
168
+ wasted: 0,
169
+ selfMs: 10,
170
+ vendor: false,
171
+ ...over,
172
+ });
173
+
174
+ test('components: improved, degraded, added and removed are separated and ranked', () => {
175
+ const before = makeReport({
176
+ react: {
177
+ components: [
178
+ component('Row', { renders: 2000, wasted: 1500, selfMs: 400 }),
179
+ component('Chart', { selfMs: 20 }),
180
+ component('Gone', { renders: 80 }),
181
+ component('Stable'),
182
+ ],
183
+ },
184
+ });
185
+ const after = makeReport({
186
+ react: {
187
+ components: [
188
+ component('Row', { renders: 400, wasted: 30, selfMs: 60 }),
189
+ component('Chart', { selfMs: 220 }),
190
+ component('Fresh', { renders: 90, selfMs: 30 }),
191
+ component('Stable'),
192
+ ],
193
+ },
194
+ });
195
+ const { components } = compareSides(side([before]), side([after]));
196
+ assert.deepEqual(
197
+ components.improved.map(c => c.name),
198
+ ['Row'],
199
+ );
200
+ assert.deepEqual(
201
+ components.degraded.map(c => c.name),
202
+ ['Chart'],
203
+ );
204
+ assert.deepEqual(
205
+ components.added.map(c => c.name),
206
+ ['Fresh'],
207
+ );
208
+ assert.deepEqual(
209
+ components.removed.map(c => c.name),
210
+ ['Gone'],
211
+ );
212
+ assert.equal(components.unchanged, 1);
213
+ const row = components.improved[0];
214
+ assert.deepEqual(
215
+ row.changes
216
+ .filter(c => c.verdict === 'improved')
217
+ .map(c => c.id)
218
+ .sort(),
219
+ ['renders', 'selfMs', 'wasted'],
220
+ );
221
+ });
222
+
223
+ test('functions and files are compared by their own key', () => {
224
+ const fn = (name, selfMs) => ({
225
+ name,
226
+ selfMs,
227
+ totalMs: selfMs,
228
+ line: 5,
229
+ lineKind: 'located',
230
+ });
231
+ const file = (path, selfMs, functions) => ({
232
+ path,
233
+ selfMs,
234
+ blockMs: selfMs,
235
+ reactMs: 0,
236
+ forcedCount: 0,
237
+ vendor: false,
238
+ functions,
239
+ });
240
+ const before = makeReport({
241
+ files: [file('src/a.ts', 300, [fn('hot', 280)])],
242
+ });
243
+ const after = makeReport({ files: [file('src/a.ts', 50, [fn('hot', 30)])] });
244
+ const cmp = compareSides(side([before]), side([after]));
245
+ assert.equal(cmp.files.improved[0].name, 'src/a.ts');
246
+ assert.equal(cmp.functions.improved[0].name, 'hot');
247
+ assert.equal(cmp.functions.improved[0].file, 'src/a.ts');
248
+ });
249
+
250
+ test('findings: new, resolved and persisting are matched by stable key, not position', () => {
251
+ const finding = (id, over = {}) => ({
252
+ id,
253
+ severity: 'warning',
254
+ category: 'CPU',
255
+ title: id,
256
+ impactMs: 100,
257
+ culprits: [],
258
+ fix: 'fix',
259
+ ...over,
260
+ });
261
+ const culprit = fn => ({ fn, file: 'src/a.ts', line: 1, ms: 1 });
262
+ const before = makeReport({
263
+ findings: [
264
+ finding('long-task-0', { culprits: [culprit('burn')] }),
265
+ finding('wasted-Row-src/Row.tsx', { severity: 'critical' }),
266
+ finding('hot-file-src/old.ts'),
267
+ ],
268
+ });
269
+ // the same long task is now at index 2, a new forced-reflow finding appeared, two were fixed
270
+ const after = makeReport({
271
+ findings: [
272
+ finding('long-task-2', { culprits: [culprit('burn')], impactMs: 60 }),
273
+ finding('forced-src/b.ts-measure', { severity: 'critical' }),
274
+ ],
275
+ });
276
+ const { findings } = compareSides(side([before]), side([after]));
277
+ assert.equal(findingKey(before.findings[0]), 'long-task:src/a.ts:burn');
278
+ assert.deepEqual(
279
+ findings.added.map(f => f.id),
280
+ ['forced-src/b.ts-measure'],
281
+ );
282
+ assert.deepEqual(
283
+ findings.resolved.map(f => f.id),
284
+ ['wasted-Row-src/Row.tsx', 'hot-file-src/old.ts'],
285
+ );
286
+ assert.equal(findings.persisting.length, 1);
287
+ assert.equal(findings.persisting[0].beforeImpactMs, 100);
288
+ });
289
+
290
+ test("a finding must appear in half of a side's runs to count", () => {
291
+ const f = id => ({
292
+ id,
293
+ severity: 'warning',
294
+ category: 'CPU',
295
+ title: id,
296
+ impactMs: 10,
297
+ culprits: [],
298
+ fix: '',
299
+ });
300
+ const runs = [
301
+ makeReport({ findings: [f('flaky')] }),
302
+ makeReport({ findings: [] }),
303
+ makeReport({ findings: [] }),
304
+ ];
305
+ const cmp = compareSides(
306
+ side(runs),
307
+ side([makeReport(), makeReport(), makeReport()]),
308
+ );
309
+ assert.equal(
310
+ cmp.findings.resolved.length,
311
+ 0,
312
+ 'seen in 1 of 3 runs: not established',
313
+ );
314
+ });
315
+
316
+ test('comparability checks and confidence', () => {
317
+ const a = makeReport({
318
+ meta: { git: { hash: 'a'.repeat(40), shortHash: 'aaaaaaa', dirty: false } },
319
+ });
320
+ const clean = compareSides(
321
+ side([a, a, a]),
322
+ side([makeReport(), makeReport(), makeReport()]),
323
+ );
324
+ assert.equal(clean.confidence, 'high');
325
+ assert.ok(clean.checks.every(c => c.level === 'ok'));
326
+
327
+ const single = compareSides(side([a]), side([makeReport()]));
328
+ assert.equal(single.confidence, 'medium');
329
+ assert.equal(single.checks.find(c => c.id === 'runs').level, 'warn');
330
+
331
+ const differentPage = compareSides(
332
+ side([a, a, a]),
333
+ side([
334
+ makeReport({ meta: { url: 'http://localhost:3000/dashboards/2/' } }),
335
+ makeReport(),
336
+ makeReport(),
337
+ ]),
338
+ );
339
+ assert.equal(differentPage.checks.find(c => c.id === 'page').level, 'warn');
340
+
341
+ const failed = compareSides(
342
+ side([a]),
343
+ side([makeReport({ meta: { navStatus: 500 } })]),
344
+ );
345
+ assert.equal(failed.confidence, 'low');
346
+ assert.equal(failed.checks.find(c => c.id === 'failed-page').level, 'bad');
347
+
348
+ const same = compareSides(side([a], a.meta.git), side([a], a.meta.git));
349
+ assert.ok(same.checks.some(c => c.id === 'same-commit'));
350
+
351
+ const dirty = compareSides(
352
+ side([a]),
353
+ side([makeReport()], { hash: 'b'.repeat(40), dirty: true }),
354
+ );
355
+ assert.ok(dirty.checks.some(c => c.id === 'dirty'));
356
+ });
357
+
358
+ test('commit and author come from the side override, else from the report', () => {
359
+ const git = {
360
+ hash: 'c'.repeat(40),
361
+ shortHash: 'ccccccc',
362
+ author: 'Ada Lovelace',
363
+ subject: 'Memoise Row',
364
+ };
365
+ const fromReport = compareSides(
366
+ side([makeReport({ meta: { git } })]),
367
+ side([makeReport()]),
368
+ );
369
+ assert.equal(fromReport.before.git.author, 'Ada Lovelace');
370
+ const override = compareSides(
371
+ side([makeReport({ meta: { git } })], {
372
+ hash: 'd'.repeat(40),
373
+ author: 'Grace Hopper',
374
+ }),
375
+ side([makeReport()]),
376
+ );
377
+ assert.equal(override.before.git.author, 'Grace Hopper');
378
+ assert.equal(compareSides(side([]), side([makeReport()])), null);
379
+ });
380
+
381
+ test('snapshot helpers: validation, commit in the file name, labelled copies', () => {
382
+ assert.equal(validateReport(makeReport()).ok, true);
383
+ assert.equal(validateReport({ traceEvents: [] }).ok, false);
384
+ assert.match(validateReport([{}]).reason, /raw Chrome trace/);
385
+ assert.equal(validateReport({ foo: 1 }).ok, false);
386
+
387
+ assert.equal(
388
+ hashFromFileName('perf_3fa516cd22_load_20260930-1548.json'),
389
+ '3fa516cd22',
390
+ );
391
+ assert.equal(
392
+ hashFromFileName('my run 938be3d83b9 after.json'),
393
+ '938be3d83b9',
394
+ );
395
+ assert.equal(
396
+ hashFromFileName('20260930T100000-live.json'),
397
+ '',
398
+ 'a date is not a commit',
399
+ );
400
+ assert.equal(hashFromFileName('report.json'), '');
401
+
402
+ const report = makeReport();
403
+ const git = {
404
+ hash: '3fa516cd22c30f21e2cb4803378dc7a88dd2a4b2',
405
+ shortHash: '3fa516cd22',
406
+ };
407
+ assert.equal(
408
+ snapshotFileName(report, git, 'Dashboard 579!'),
409
+ 'perf_3fa516cd22_load_20260930-100000_dashboard-579.json',
410
+ );
411
+ assert.equal(
412
+ snapshotFileName(report, null, ''),
413
+ 'perf_nocommit_load_20260930-100000.json',
414
+ );
415
+ const labelled = withCommit(report, git, 'x');
416
+ assert.equal(labelled.meta.git.shortHash, '3fa516cd22');
417
+ assert.equal(report.meta.git, null, 'the original is not mutated');
418
+ });
419
+
420
+ test('the PDF html escapes names coming from the profiled app and differs by variant', async () => {
421
+ const { renderComparisonHtml } = await import('./pdf/template.js');
422
+ const evil = '<img src=x onerror=alert(1)>';
423
+ const git = {
424
+ hash: 'a'.repeat(40),
425
+ shortHash: 'aaaaaaa',
426
+ author: 'Ada <b>Lovelace</b>',
427
+ subject: 'x',
428
+ date: '2026-09-30T10:00:00Z',
429
+ };
430
+ const before = makeReport({
431
+ react: {
432
+ components: [component(evil, { renders: 900, wasted: 800, selfMs: 300 })],
433
+ },
434
+ findings: [
435
+ {
436
+ id: 'wasted-x',
437
+ severity: 'critical',
438
+ category: 'React',
439
+ title: evil,
440
+ impactMs: 5,
441
+ culprits: [{ fn: evil, file: 'src/a.tsx', line: 1 }],
442
+ fix: 'f',
443
+ },
444
+ ],
445
+ });
446
+ const after = makeReport({
447
+ react: {
448
+ components: [component(evil, { renders: 10, wasted: 0, selfMs: 5 })],
449
+ },
450
+ });
451
+ const cmp = compareSides(
452
+ side([before], git),
453
+ side([after], { ...git, hash: 'b'.repeat(40), shortHash: 'bbbbbbb' }),
454
+ );
455
+ const summary = renderComparisonHtml(cmp, 'summary');
456
+ const detailed = renderComparisonHtml(cmp, 'detailed');
457
+ for (const html of [summary, detailed]) {
458
+ assert.ok(
459
+ !html.includes('<img src=x'),
460
+ 'raw markup must never reach the document',
461
+ );
462
+ assert.ok(!html.includes('<b>Lovelace'), 'author is escaped too');
463
+ assert.ok(html.includes('aaaaaaa') && html.includes('bbbbbbb'));
464
+ assert.ok(html.includes('&lt;img'));
465
+ }
466
+ assert.ok(
467
+ detailed.length > summary.length * 1.3,
468
+ 'the detailed report carries more',
469
+ );
470
+ assert.match(detailed, /Methodology/);
471
+ assert.doesNotMatch(summary, /Methodology/);
472
+ assert.match(summary, /Improved|Regressed|Mixed|No significant change/);
473
+ });
@@ -0,0 +1,136 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import {
4
+ SourceResolver,
5
+ indexWebpackModules,
6
+ moduleAt,
7
+ normalizePath,
8
+ packageOf,
9
+ } from './analysis/sources.js';
10
+
11
+ const ROOT = '/repo/target-frontend';
12
+
13
+ test('normalizePath handles every bundler spelling', () => {
14
+ assert.equal(
15
+ normalizePath('webpack://target/./src/a/b.tsx?1a2b', ROOT),
16
+ 'src/a/b.tsx',
17
+ );
18
+ assert.equal(
19
+ normalizePath('webpack-internal:///./src/a/b.tsx', ROOT),
20
+ 'src/a/b.tsx',
21
+ );
22
+ assert.equal(
23
+ normalizePath(
24
+ './node_modules/react-dom/cjs/react-dom.development.js',
25
+ ROOT,
26
+ ),
27
+ 'node_modules/react-dom/cjs/react-dom.development.js',
28
+ );
29
+ assert.equal(
30
+ normalizePath('../node_modules/lodash/lodash.js', ROOT),
31
+ 'node_modules/lodash/lodash.js',
32
+ );
33
+ assert.equal(
34
+ normalizePath(`${ROOT}/packages/example-lib/src/x.ts`, ROOT),
35
+ 'packages/example-lib/src/x.ts',
36
+ );
37
+ assert.equal(normalizePath('./src/a.tsx + 3 modules', ROOT), 'src/a.tsx');
38
+ });
39
+
40
+ test('packageOf finds npm package names, including scoped and vite-prebundled ones', () => {
41
+ assert.equal(packageOf('node_modules/react/index.js'), 'react');
42
+ assert.equal(
43
+ packageOf('node_modules/@emotion/react/dist/x.js'),
44
+ '@emotion/react',
45
+ );
46
+ assert.equal(
47
+ packageOf('node_modules/.vite/deps/react-dom_client.js'),
48
+ 'react-dom',
49
+ );
50
+ assert.equal(packageOf('src/app.ts'), null);
51
+ });
52
+
53
+ const bundle = [
54
+ '(() => {',
55
+ '/******/ var __webpack_modules__ = ({',
56
+ '',
57
+ '/***/ "./src/a.tsx":',
58
+ '/*!******************!*\\',
59
+ ' !*** ./src/a.tsx ***!',
60
+ ' \\******************/',
61
+ '/***/ ((module) => {',
62
+ 'function a() {}',
63
+ '/***/ }),',
64
+ '',
65
+ '/***/ "./node_modules/left-pad/index.js":',
66
+ '/***/ ((module) => {',
67
+ 'function pad() {}',
68
+ '/***/ })',
69
+ '/******/ });',
70
+ ].join('\n');
71
+
72
+ test('the inlined entry module is found through its banner comment', () => {
73
+ const source = [
74
+ '/***/ "./src/a.tsx":',
75
+ '/***/ ((m) => { function a() {} }),',
76
+ '/******/ });',
77
+ '// This entry needs to be wrapped in an IIFE because it needs to be in strict mode.',
78
+ '(() => {',
79
+ '/*!******************!*\\',
80
+ ' !*** ./src/main.jsx ***!',
81
+ ' \\******************/',
82
+ 'function App() {}',
83
+ '})();',
84
+ ].join('\n');
85
+ const markers = indexWebpackModules(source);
86
+ assert.equal(moduleAt(markers, 1), './src/a.tsx');
87
+ assert.equal(moduleAt(markers, 8), './src/main.jsx');
88
+ });
89
+
90
+ test('webpack module markers map bundle lines back to module ids', () => {
91
+ const markers = indexWebpackModules(bundle);
92
+ assert.deepEqual(
93
+ markers.map(m => m.id),
94
+ ['./src/a.tsx', './node_modules/left-pad/index.js'],
95
+ );
96
+ assert.equal(moduleAt(markers, 8), './src/a.tsx');
97
+ assert.equal(moduleAt(markers, 13), './node_modules/left-pad/index.js');
98
+ assert.equal(moduleAt(markers, 1), null);
99
+ });
100
+
101
+ test('resolver falls back from source map to markers to URLs', async () => {
102
+ const scripts = new Map([
103
+ ['1', { url: 'http://localhost:3000/static/assets/main.js' }],
104
+ ['2', { url: 'http://localhost:5177/src/App.jsx?t=1' }],
105
+ ['3', { url: 'pptr:evaluate;x' }],
106
+ ['4', { url: 'http://localhost:3000/static/assets/app.abcdef12.chunk.js' }],
107
+ ]);
108
+ const resolver = new SourceResolver({
109
+ root: ROOT,
110
+ scripts,
111
+ getSource: async id => (id === '1' ? bundle : 'console.log(1)'),
112
+ });
113
+ await resolver.prepare(['1', '2', '3', '4']);
114
+ assert.equal(
115
+ resolver.resolve('1', scripts.get('1').url, 8, 0).file,
116
+ 'src/a.tsx',
117
+ );
118
+ const vendor = resolver.resolve('1', scripts.get('1').url, 13, 0);
119
+ assert.deepEqual(
120
+ [vendor.file, vendor.vendor, vendor.pkg],
121
+ ['node_modules/left-pad/index.js', true, 'left-pad'],
122
+ );
123
+ assert.equal(
124
+ resolver.resolve('2', scripts.get('2').url, 0, 0).file,
125
+ 'src/App.jsx',
126
+ );
127
+ assert.equal(
128
+ resolver.resolve('3', scripts.get('3').url, 0, 0).file,
129
+ '(injected)',
130
+ );
131
+ assert.equal(
132
+ resolver.resolve('4', scripts.get('4').url, 0, 0).file,
133
+ 'bundle:app.abcdef12.chunk.js',
134
+ );
135
+ assert.deepEqual([...resolver.kinds].sort(), ['urls', 'webpack-markers']);
136
+ });