react-spot 0.0.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.
@@ -0,0 +1,415 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ clearCaches,
4
+ configureSourceRoot,
5
+ extractStackFrameInfo,
6
+ fetchSourceFile,
7
+ resolveLocation,
8
+ resolveSourcePath,
9
+ } from './source-location-resolver';
10
+
11
+ // ─── extractStackFrameInfo ───────────────────────────────────────────────────
12
+
13
+ describe('extractStackFrameInfo', () => {
14
+ it('parses about://React/Server/file:/// stack frames correctly', () => {
15
+ const frame =
16
+ 'at LandingPage (about://React/Server/file:///Users/testuser/Projects/sample-app/web/.next/server/chunks/ssr/%5Broot-of-the-server%5D__63dfaf64._.js?20:141:295)';
17
+ const info = extractStackFrameInfo(frame);
18
+
19
+ expect(info).toBeDefined();
20
+ expect(info?.functionName).toBe('LandingPage');
21
+ expect(info?.url).toBe(
22
+ 'about://React/Server/file:///Users/testuser/Projects/sample-app/web/.next/server/chunks/ssr/%5Broot-of-the-server%5D__63dfaf64._.js?20'
23
+ );
24
+ expect(info?.line).toBe(141);
25
+ expect(info?.column).toBe(295);
26
+ });
27
+
28
+ it('parses regular http stack frames as before', () => {
29
+ const frame =
30
+ 'at fakeJSXCallSite (http://localhost:3000/_next/static/chunks/2374f_next_dist_compiled_20dc070b._.js:4353:16)';
31
+ const info = extractStackFrameInfo(frame);
32
+
33
+ expect(info).toBeDefined();
34
+ expect(info?.functionName).toBe('fakeJSXCallSite');
35
+ expect(info?.url).toBe(
36
+ 'http://localhost:3000/_next/static/chunks/2374f_next_dist_compiled_20dc070b._.js'
37
+ );
38
+ expect(info?.line).toBe(4353);
39
+ expect(info?.column).toBe(16);
40
+ });
41
+
42
+ it('parses rsc:// stack frames', () => {
43
+ const frame =
44
+ 'at ServerPage (rsc://React/Server/file:///Users/testuser/Projects/sample-app/web/.next/server/chunks/ssr/file.js:10:20)';
45
+ const info = extractStackFrameInfo(frame);
46
+
47
+ expect(info).toBeDefined();
48
+ expect(info?.functionName).toBe('ServerPage');
49
+ expect(info?.url).toBe(
50
+ 'rsc://React/Server/file:///Users/testuser/Projects/sample-app/web/.next/server/chunks/ssr/file.js'
51
+ );
52
+ expect(info?.line).toBe(10);
53
+ expect(info?.column).toBe(20);
54
+ });
55
+ });
56
+
57
+ // ─── fetchSourceFile ─────────────────────────────────────────────────────────
58
+
59
+ describe('fetchSourceFile', () => {
60
+ const originalFetch = globalThis.fetch;
61
+ let hadWindow: boolean;
62
+
63
+ beforeEach(() => {
64
+ clearCaches();
65
+ hadWindow = typeof globalThis.window !== 'undefined';
66
+ if (!hadWindow) {
67
+ (globalThis as unknown as Record<string, unknown>).window = {
68
+ location: { origin: 'http://localhost:3000' },
69
+ };
70
+ }
71
+ });
72
+
73
+ afterEach(() => {
74
+ globalThis.fetch = originalFetch;
75
+ if (!hadWindow) {
76
+ (globalThis as unknown as Record<string, unknown>).window = undefined as unknown as Window &
77
+ typeof globalThis;
78
+ }
79
+ });
80
+
81
+ it('fetches regular HTTP URLs directly', async () => {
82
+ const mockFetch = vi.fn().mockResolvedValue({
83
+ ok: true,
84
+ text: () => Promise.resolve('// js source'),
85
+ });
86
+ globalThis.fetch = mockFetch;
87
+
88
+ const result = await fetchSourceFile('http://localhost:3000/_next/static/chunks/app.js');
89
+ expect(result.content).toBe('// js source');
90
+ expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:3000/_next/static/chunks/app.js');
91
+ });
92
+
93
+ it('resolves root-relative URLs against window.location.origin', async () => {
94
+ const mockFetch = vi.fn().mockResolvedValue({
95
+ ok: true,
96
+ text: () => Promise.resolve('// relative source'),
97
+ });
98
+ globalThis.fetch = mockFetch;
99
+
100
+ const result = await fetchSourceFile('/_next/static/chunks/app.js');
101
+ expect(result.content).toBe('// relative source');
102
+ expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:3000/_next/static/chunks/app.js');
103
+ });
104
+
105
+ it('rejects RSC URLs (handled by Next.js fast path, not fetchSourceFile)', async () => {
106
+ await expect(
107
+ fetchSourceFile('about://React/Server/file:///Users/me/proj/.next/server/chunks/ssr/page.js')
108
+ ).rejects.toThrow(/non-fetchable URL scheme/i);
109
+
110
+ await expect(
111
+ fetchSourceFile('rsc://React/Server/file:///Users/me/proj/.next/server/chunks/ssr/page.js')
112
+ ).rejects.toThrow(/non-fetchable URL scheme/i);
113
+ });
114
+
115
+ it('rejects other non-http schemes', async () => {
116
+ await expect(fetchSourceFile('chrome-extension://abcdef/background.js')).rejects.toThrow(
117
+ /non-fetchable URL scheme/i
118
+ );
119
+ });
120
+ });
121
+
122
+ // ─── resolveSourcePath ──────────────────────────────────────────────────────
123
+
124
+ describe('resolveSourcePath', () => {
125
+ afterEach(() => {
126
+ configureSourceRoot(undefined);
127
+ });
128
+
129
+ it('strips file:/// protocol and returns the absolute path directly', () => {
130
+ const result = resolveSourcePath(
131
+ 'file:///Users/me/project/src/components/Foo.tsx',
132
+ undefined,
133
+ 'http://localhost:3000/_next/static/chunks/app_src_abc123._.js'
134
+ );
135
+ expect(result).toBe('/Users/me/project/src/components/Foo.tsx');
136
+ });
137
+
138
+ it('does not double-prefix file:/// sources with sourceRoot', () => {
139
+ configureSourceRoot('/Users/me/project');
140
+ const result = resolveSourcePath(
141
+ 'file:///Users/me/project/src/components/Foo.tsx',
142
+ undefined,
143
+ 'http://localhost:3000/_next/static/chunks/app_src_abc123._.js'
144
+ );
145
+ // Should return the path as-is from the file:// URL, not prepend sourceRoot
146
+ expect(result).toBe('/Users/me/project/src/components/Foo.tsx');
147
+ });
148
+
149
+ it('resolves relative sources against the source file URL', () => {
150
+ const result = resolveSourcePath(
151
+ 'Foo.tsx',
152
+ undefined,
153
+ 'http://localhost:5200/src/scenarios/Foo.tsx'
154
+ );
155
+ expect(result).toBe('/src/scenarios/Foo.tsx');
156
+ });
157
+
158
+ it('handles absolute paths starting with / that contain /src/', () => {
159
+ const result = resolveSourcePath(
160
+ '/Users/me/project/src/components/Foo.tsx',
161
+ undefined,
162
+ 'http://localhost:3000/app.js'
163
+ );
164
+ expect(result).toBe('/Users/me/project/src/components/Foo.tsx');
165
+ });
166
+
167
+ it('strips webpack:/// prefix', () => {
168
+ const result = resolveSourcePath(
169
+ 'webpack:///src/components/Foo.tsx',
170
+ undefined,
171
+ 'http://localhost:3000/app.js'
172
+ );
173
+ expect(result).toBe('/src/components/Foo.tsx');
174
+ });
175
+
176
+ it('prepends sourceRoot to Turbopack virtual /src/ path when sourceRoot is configured', () => {
177
+ // Turbopack source maps emit root-relative paths like /src/components/Foo.tsx.
178
+ // Without this fix the /src/ short-circuit would skip sourceRoot, causing
179
+ // the editor to try opening "/src/components/Foo.tsx" which does not exist.
180
+ configureSourceRoot('/Users/me/project');
181
+ const result = resolveSourcePath(
182
+ '/src/components/sidebar/ProbeHintItem.tsx',
183
+ undefined,
184
+ 'http://localhost:3000/_next/static/chunks/app_src_abc123._.js'
185
+ );
186
+ expect(result).toBe('/Users/me/project/src/components/sidebar/ProbeHintItem.tsx');
187
+ });
188
+
189
+ it('does not double-prefix when path already starts with sourceRoot', () => {
190
+ // 真实绝对路径(如来自 file:// 后已剥离协议头)不应再次拼接 sourceRoot
191
+ configureSourceRoot('/Users/me/project');
192
+ const result = resolveSourcePath(
193
+ '/Users/me/project/src/components/Foo.tsx',
194
+ undefined,
195
+ 'http://localhost:3000/_next/static/chunks/app_src_abc123._.js'
196
+ );
197
+ expect(result).toBe('/Users/me/project/src/components/Foo.tsx');
198
+ });
199
+ });
200
+
201
+ // ─── resolveLocation — Next.js RSC fast path ───────────────────────────────
202
+
203
+ describe('resolveLocation — Next.js dev server integration', () => {
204
+ const originalFetch = globalThis.fetch;
205
+ let hadWindow: boolean;
206
+
207
+ beforeEach(() => {
208
+ clearCaches();
209
+ hadWindow = typeof globalThis.window !== 'undefined';
210
+ if (!hadWindow) {
211
+ (globalThis as unknown as Record<string, unknown>).window = {
212
+ location: { origin: 'http://localhost:3000' },
213
+ };
214
+ }
215
+ });
216
+
217
+ afterEach(() => {
218
+ globalThis.fetch = originalFetch;
219
+ if (!hadWindow) {
220
+ (globalThis as unknown as Record<string, unknown>).window = undefined as unknown as Window &
221
+ typeof globalThis;
222
+ }
223
+ });
224
+
225
+ const nextjsRscStackLine =
226
+ 'at LandingPage (about://React/Server/file:///Users/testuser/Projects/sample-app/web/.next/server/chunks/ssr/%5Broot-of-the-server%5D__63dfaf64._.js?20:141:295)';
227
+
228
+ it('resolves via __nextjs_original-stack-frames for Next.js RSC URLs', async () => {
229
+ const mockFetch = vi.fn().mockResolvedValue({
230
+ ok: true,
231
+ json: () =>
232
+ Promise.resolve([
233
+ {
234
+ status: 'fulfilled',
235
+ value: {
236
+ originalStackFrame: {
237
+ file: '/Users/testuser/Projects/sample-app/web/src/app/page.tsx',
238
+ methodName: 'LandingPage',
239
+ arguments: [],
240
+ line1: 42,
241
+ column1: 10,
242
+ ignored: false,
243
+ },
244
+ originalCodeFrame: ' 42 | export default function LandingPage() {',
245
+ },
246
+ },
247
+ ]),
248
+ });
249
+ globalThis.fetch = mockFetch;
250
+
251
+ const result = await resolveLocation(nextjsRscStackLine);
252
+
253
+ expect(result).not.toBeNull();
254
+ expect(result?.source).toBe('/Users/testuser/Projects/sample-app/web/src/app/page.tsx');
255
+ expect(result?.line).toBe(42);
256
+ expect(result?.column).toBe(10);
257
+ expect(result?.name).toBe('LandingPage');
258
+
259
+ // Should have posted to __nextjs_original-stack-frames
260
+ expect(mockFetch).toHaveBeenCalledTimes(1);
261
+ const [url, options] = mockFetch.mock.calls[0];
262
+ expect(url).toBe('http://localhost:3000/__nextjs_original-stack-frames');
263
+ expect(options.method).toBe('POST');
264
+
265
+ const body = JSON.parse(options.body);
266
+ expect(body.isServer).toBe(true);
267
+ expect(body.frames).toHaveLength(1);
268
+ expect(body.frames[0].file).toBe(
269
+ '/Users/testuser/Projects/sample-app/web/.next/server/chunks/ssr/[root-of-the-server]__63dfaf64._.js'
270
+ );
271
+ expect(body.frames[0].line1).toBe(141);
272
+ expect(body.frames[0].column1).toBe(295);
273
+ });
274
+
275
+ it('falls back to __nextjs_source-map when __nextjs_original-stack-frames fails', async () => {
276
+ const mockFetch = vi
277
+ .fn()
278
+ // First call: __nextjs_original-stack-frames returns error
279
+ .mockResolvedValueOnce({ ok: false, status: 500 })
280
+ // Second call: __nextjs_source-map returns a valid source map
281
+ .mockResolvedValueOnce({
282
+ ok: true,
283
+ text: () =>
284
+ Promise.resolve(
285
+ JSON.stringify({
286
+ version: 3,
287
+ file: 'output.js',
288
+ sources: ['file:///Users/testuser/Projects/sample-app/web/src/app/page.tsx'],
289
+ sourcesContent: [
290
+ 'export default function LandingPage() {\n return <div>Hello</div>;\n}',
291
+ ],
292
+ names: ['LandingPage'],
293
+ mappings:
294
+ ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oBAAmB',
295
+ })
296
+ ),
297
+ });
298
+ globalThis.fetch = mockFetch;
299
+
300
+ await resolveLocation(nextjsRscStackLine);
301
+
302
+ // Should have called both endpoints
303
+ expect(mockFetch).toHaveBeenCalledTimes(2);
304
+ expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:3000/__nextjs_original-stack-frames');
305
+ expect(mockFetch.mock.calls[1][0]).toContain('/__nextjs_source-map?filename=');
306
+ });
307
+
308
+ it('returns null when both Next.js endpoints fail', async () => {
309
+ const mockFetch = vi
310
+ .fn()
311
+ // __nextjs_original-stack-frames: 404
312
+ .mockResolvedValueOnce({ ok: false, status: 404 })
313
+ // __nextjs_source-map: 404
314
+ .mockResolvedValueOnce({ ok: false, status: 404 });
315
+ globalThis.fetch = mockFetch;
316
+
317
+ const result = await resolveLocation(nextjsRscStackLine);
318
+
319
+ expect(result).toBeNull();
320
+ });
321
+
322
+ it('caches Next.js dev server unavailability (404) and skips on subsequent calls', async () => {
323
+ const mockFetch = vi
324
+ .fn()
325
+ // First attempt: 404 for stack-frames, 404 for source-map
326
+ .mockResolvedValueOnce({ ok: false, status: 404 })
327
+ .mockResolvedValueOnce({ ok: false, status: 404 })
328
+ // Second attempt: should skip stack-frames endpoint entirely
329
+ .mockResolvedValueOnce({ ok: false, status: 404 });
330
+ globalThis.fetch = mockFetch;
331
+
332
+ // First call — probes and gets 404, caches unavailability
333
+ await resolveLocation(nextjsRscStackLine);
334
+
335
+ // Second call — should skip __nextjs_original-stack-frames (cached 404)
336
+ // and go directly to __nextjs_source-map
337
+ await resolveLocation(
338
+ 'at Foo (about://React/Server/file:///Users/testuser/Projects/other/.next/server/chunks/ssr/foo.js:1:1)'
339
+ );
340
+
341
+ // First call: stack-frames(404) + source-map(404) = 2 fetches
342
+ // Second call: only source-map(404) = 1 fetch (skipped stack-frames)
343
+ expect(mockFetch).toHaveBeenCalledTimes(3);
344
+ // The third fetch should be __nextjs_source-map (not stack-frames)
345
+ expect(mockFetch.mock.calls[2][0]).toContain('/__nextjs_source-map');
346
+ });
347
+
348
+ it('does not use Next.js fast path for non-Next.js RSC URLs', async () => {
349
+ // RSC URL without /.next/ — not a Next.js project
350
+ const nonNextjsRscLine =
351
+ 'at Comp (rsc://React/Server/file:///Users/testuser/Projects/other/build/server/page.js:5:10)';
352
+
353
+ const mockFetch = vi.fn();
354
+ globalThis.fetch = mockFetch;
355
+
356
+ // Non-Next.js RSC URLs skip the Next.js fast path and fall through to the
357
+ // standard path where they're rejected as non-fetchable (rsc:// scheme)
358
+ const result = await resolveLocation(nonNextjsRscLine);
359
+
360
+ expect(result).toBeNull();
361
+ // Should NOT have called any Next.js endpoint — the rsc:// URL is rejected
362
+ // by hasNonFetchableScheme before any fetch occurs
363
+ expect(mockFetch).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it('does not use Next.js fast path for regular HTTP URLs', async () => {
367
+ const httpStackLine = 'at Component (http://localhost:3000/_next/static/chunks/app.js:100:20)';
368
+
369
+ const mockFetch = vi.fn().mockResolvedValue({
370
+ ok: true,
371
+ text: () => Promise.resolve('// js code without source map'),
372
+ });
373
+ globalThis.fetch = mockFetch;
374
+
375
+ await resolveLocation(httpStackLine);
376
+
377
+ // Should fetch the JS file directly, not use Next.js endpoints
378
+ expect(mockFetch).toHaveBeenCalledTimes(1);
379
+ expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:3000/_next/static/chunks/app.js');
380
+ });
381
+
382
+ it('caches the resolved result from Next.js dev server (L1 cache)', async () => {
383
+ const mockFetch = vi.fn().mockResolvedValue({
384
+ ok: true,
385
+ json: () =>
386
+ Promise.resolve([
387
+ {
388
+ status: 'fulfilled',
389
+ value: {
390
+ originalStackFrame: {
391
+ file: '/Users/testuser/Projects/sample-app/web/src/app/page.tsx',
392
+ methodName: 'LandingPage',
393
+ arguments: [],
394
+ line1: 42,
395
+ column1: 10,
396
+ ignored: false,
397
+ },
398
+ originalCodeFrame: null,
399
+ },
400
+ },
401
+ ]),
402
+ });
403
+ globalThis.fetch = mockFetch;
404
+
405
+ // First call — hits the endpoint
406
+ const result1 = await resolveLocation(nextjsRscStackLine);
407
+ expect(result1).not.toBeNull();
408
+ expect(mockFetch).toHaveBeenCalledTimes(1);
409
+
410
+ // Second call — should use L1 cache, no additional fetch
411
+ const result2 = await resolveLocation(nextjsRscStackLine);
412
+ expect(result2).toEqual(result1);
413
+ expect(mockFetch).toHaveBeenCalledTimes(1); // still 1
414
+ });
415
+ });