Skip to content

Commit d05a4a2

Browse files
committed
feat: implement error overlay functionality with blob URL handling
1 parent 4071d80 commit d05a4a2

14 files changed

Lines changed: 1231 additions & 29 deletions

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"js/ts.tsdk.path": "node_modules\\typescript\\lib"
2+
"js/ts.tsdk.path": "node_modules/typescript/lib"
33
}

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@
99
"browser": "dist/index.cdn.mjs",
1010
"types": "dist/index.d.ts",
1111
"scripts": {
12+
"dev": "tsdown --watch",
1213
"build": "tsdown",
1314
"test": "node --test 'src/*.test.ts'"
1415
},
1516
"dependencies": {
1617
"typescript": "^5.9.3"
1718
},
1819
"devDependencies": {
19-
"@types/node": "^24.12.3",
20-
"esbuild": "^0.28.0",
21-
"tsdown": "^0.22.0"
20+
"@types/node": "^24.13.2",
21+
"esbuild": "^0.28.1",
22+
"tsdown": "^0.22.3"
2223
},
2324
"author": "YieldRay",
2425
"license": "MIT",
@@ -33,5 +34,5 @@
3334
"publishConfig": {
3435
"registry": "https://registry.npmjs.org"
3536
},
36-
"packageManager": "pnpm@10.33.4"
37+
"packageManager": "pnpm@10.34.4"
3738
}

src/error-overlay.test.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { replaceBlobUrls, extractBlobLocation, rewriteStack } from "./error-overlay.ts";
4+
import type { ModuleTSX } from "./module-tsx.ts";
5+
6+
/**
7+
* Create a minimal mock of ModuleTSX that implements the methods used by
8+
* replaceBlobUrls, extractBlobLocation, and rewriteStack.
9+
*/
10+
function mockInstance(opts: {
11+
blobToSource?: Record<string, string>;
12+
blobToOriginal?: Record<string, string>;
13+
}) {
14+
return {
15+
getSourceUrlByBlob(blobUrl: string): string | undefined {
16+
return opts.blobToSource?.[blobUrl];
17+
},
18+
getOriginalSource(blobUrl: string): string | undefined {
19+
return opts.blobToOriginal?.[blobUrl];
20+
},
21+
} as unknown as ModuleTSX;
22+
}
23+
24+
// ─── extractBlobLocation ─────────────────────────────────────────────────────
25+
26+
describe("extractBlobLocation", () => {
27+
it("extracts blob URL with line and col from stack trace", () => {
28+
const blobUrl = "blob:http://localhost:3000/abc-123";
29+
const instance = mockInstance({ blobToSource: { [blobUrl]: "http://localhost:3000/app.tsx" } });
30+
const stack = `Error: something
31+
at App (${blobUrl}:5:12)
32+
at render (${blobUrl}:10:3)`;
33+
34+
const result = extractBlobLocation(stack, instance);
35+
assert.ok(result);
36+
assert.equal(result!.blobUrl, blobUrl);
37+
assert.equal(result!.line, 5);
38+
assert.equal(result!.col, 12);
39+
});
40+
41+
it("extracts blob URL with only line number", () => {
42+
const blobUrl = "blob:http://localhost:3000/abc-123";
43+
const instance = mockInstance({ blobToSource: { [blobUrl]: "http://localhost:3000/app.tsx" } });
44+
const stack = `Error: oops at ${blobUrl}:7`;
45+
46+
const result = extractBlobLocation(stack, instance);
47+
assert.ok(result);
48+
assert.equal(result!.blobUrl, blobUrl);
49+
assert.equal(result!.line, 7);
50+
});
51+
52+
it("extracts blob URL without line number", () => {
53+
const blobUrl = "blob:http://localhost:3000/abc-123";
54+
const instance = mockInstance({ blobToSource: { [blobUrl]: "http://localhost:3000/app.tsx" } });
55+
const stack = `Error at ${blobUrl}`;
56+
57+
const result = extractBlobLocation(stack, instance);
58+
assert.ok(result);
59+
assert.equal(result!.blobUrl, blobUrl);
60+
assert.equal(result!.line, 1);
61+
});
62+
63+
it("returns undefined when no blob URL is recognized", () => {
64+
const instance = mockInstance({ blobToSource: {} });
65+
const stack = `Error: something\n at foo (http://localhost/app.js:5:12)`;
66+
67+
const result = extractBlobLocation(stack, instance);
68+
assert.equal(result, undefined);
69+
});
70+
71+
it("returns undefined for empty stack", () => {
72+
const instance = mockInstance({ blobToSource: {} });
73+
const result = extractBlobLocation("", instance);
74+
assert.equal(result, undefined);
75+
});
76+
77+
it("handles https blob URLs", () => {
78+
const blobUrl = "blob:https://example.com/def-456";
79+
const instance = mockInstance({ blobToSource: { [blobUrl]: "https://example.com/main.ts" } });
80+
const stack = ` at ${blobUrl}:3:1`;
81+
82+
const result = extractBlobLocation(stack, instance);
83+
assert.ok(result);
84+
assert.equal(result!.blobUrl, blobUrl);
85+
assert.equal(result!.line, 3);
86+
assert.equal(result!.col, 1);
87+
});
88+
});
89+
90+
// ─── replaceBlobUrls ─────────────────────────────────────────────────────────
91+
92+
describe("replaceBlobUrls", () => {
93+
it("replaces blob URL without line number", async () => {
94+
const blobUrl = "blob:http://localhost:3000/abc-123";
95+
const instance = mockInstance({ blobToSource: { [blobUrl]: "http://localhost:3000/app.tsx" } });
96+
97+
const result = await replaceBlobUrls(`Error at ${blobUrl}`, instance);
98+
assert.equal(result, "Error at http://localhost:3000/app.tsx");
99+
});
100+
101+
it("replaces blob URL with line:col using source map", async () => {
102+
const blobUrl = "blob:http://localhost:3000/abc-123";
103+
const originalSource = `const x = 1;\nconsole.log(x);\n`;
104+
const instance = mockInstance({
105+
blobToSource: { [blobUrl]: "http://localhost:3000/app.ts" },
106+
blobToOriginal: { [blobUrl]: originalSource },
107+
});
108+
109+
// Line 3 in compiled = line 2 of source (after subtracting import.meta.url line)
110+
const result = await replaceBlobUrls(`Error at ${blobUrl}:3:1`, instance);
111+
// Should use source map resolution - expect mapped line
112+
assert.ok(result.includes("http://localhost:3000/app.ts:"), result);
113+
assert.ok(!result.includes("blob:"), `should not contain blob URL: ${result}`);
114+
});
115+
116+
it("falls back to -1 adjustment when no original source available", async () => {
117+
const blobUrl = "blob:http://localhost:3000/abc-123";
118+
const instance = mockInstance({
119+
blobToSource: { [blobUrl]: "http://localhost:3000/app.ts" },
120+
blobToOriginal: {}, // no original source
121+
});
122+
123+
const result = await replaceBlobUrls(`at ${blobUrl}:5:10`, instance);
124+
assert.equal(result, "at http://localhost:3000/app.ts:4:10");
125+
});
126+
127+
it("returns text unchanged when no blob URLs are present", async () => {
128+
const instance = mockInstance({ blobToSource: {} });
129+
const text = "Error: something went wrong at http://localhost/app.js:5:12";
130+
const result = await replaceBlobUrls(text, instance);
131+
assert.equal(result, text);
132+
});
133+
134+
it("returns text unchanged when blob URL is not recognized", async () => {
135+
const instance = mockInstance({ blobToSource: {} });
136+
const text = "Error at blob:http://localhost:3000/unknown-uuid:5:12";
137+
const result = await replaceBlobUrls(text, instance);
138+
assert.equal(result, text);
139+
});
140+
141+
it("handles multiple blob URLs in the same text", async () => {
142+
const blob1 = "blob:http://localhost:3000/aaa";
143+
const blob2 = "blob:http://localhost:3000/bbb";
144+
const instance = mockInstance({
145+
blobToSource: {
146+
[blob1]: "http://localhost:3000/a.ts",
147+
[blob2]: "http://localhost:3000/b.ts",
148+
},
149+
});
150+
151+
const text = `first ${blob1} and second ${blob2}`;
152+
const result = await replaceBlobUrls(text, instance);
153+
assert.ok(result.includes("http://localhost:3000/a.ts"), result);
154+
assert.ok(result.includes("http://localhost:3000/b.ts"), result);
155+
assert.ok(!result.includes("blob:"), result);
156+
});
157+
158+
it("handles single trailing number as line number", async () => {
159+
const blobUrl = "blob:http://localhost:3000/abc-123";
160+
const instance = mockInstance({
161+
blobToSource: { [blobUrl]: "http://localhost:3000/app.ts" },
162+
blobToOriginal: {}, // no original source for fallback path
163+
});
164+
165+
// When the blob URL has only one trailing number, it's treated as a line
166+
// But first the code tries to match the blob URL with the number stripped
167+
// If blob:http://localhost:3000/abc-123 is in the map, then :5 is col (single number case)
168+
const result = await replaceBlobUrls(`at ${blobUrl}:5`, instance);
169+
// Single trailing number treated as line, adjusted -1
170+
assert.equal(result, "at http://localhost:3000/app.ts:4");
171+
});
172+
});
173+
174+
// ─── rewriteStack ────────────────────────────────────────────────────────────
175+
176+
describe("rewriteStack", () => {
177+
it("rewrites blob URLs in stack trace using source maps", async () => {
178+
const blobUrl = "blob:http://localhost:3000/abc-123";
179+
const originalSource = `const x = 1;\nconsole.log(x);\n`;
180+
const instance = mockInstance({
181+
blobToSource: { [blobUrl]: "http://localhost:3000/app.ts" },
182+
blobToOriginal: { [blobUrl]: originalSource },
183+
});
184+
185+
const stack = `Error: boom\n at foo (${blobUrl}:3:1)`;
186+
const result = await rewriteStack(stack, instance);
187+
assert.ok(result.includes("http://localhost:3000/app.ts:"), result);
188+
assert.ok(!result.includes("blob:"), `should not contain blob URL: ${result}`);
189+
});
190+
191+
it("returns stack unchanged when no blob URLs present", async () => {
192+
const instance = mockInstance({ blobToSource: {} });
193+
const stack = `Error: boom\n at foo (http://localhost/app.js:5:12)`;
194+
const result = await rewriteStack(stack, instance);
195+
assert.equal(result, stack);
196+
});
197+
198+
it("falls back to -1 adjustment when source map fails", async () => {
199+
const blobUrl = "blob:http://localhost:3000/abc-123";
200+
const instance = mockInstance({
201+
blobToSource: { [blobUrl]: "http://localhost:3000/app.ts" },
202+
blobToOriginal: {}, // no original source
203+
});
204+
205+
const stack = ` at bar (${blobUrl}:10:5)`;
206+
const result = await rewriteStack(stack, instance);
207+
assert.equal(result, " at bar (http://localhost:3000/app.ts:9:5)");
208+
});
209+
210+
it("handles multiple blob URLs in stack", async () => {
211+
const blob1 = "blob:http://localhost:3000/aaa";
212+
const blob2 = "blob:http://localhost:3000/bbb";
213+
const instance = mockInstance({
214+
blobToSource: {
215+
[blob1]: "http://localhost:3000/a.ts",
216+
[blob2]: "http://localhost:3000/b.ts",
217+
},
218+
});
219+
220+
const stack = ` at foo (${blob1}:5:1)\n at bar (${blob2}:3:1)`;
221+
const result = await rewriteStack(stack, instance);
222+
assert.ok(result.includes("http://localhost:3000/a.ts:4:1"), result);
223+
assert.ok(result.includes("http://localhost:3000/b.ts:2:1"), result);
224+
assert.ok(!result.includes("blob:"), result);
225+
});
226+
227+
it("rewrites blob URL without line number", async () => {
228+
const blobUrl = "blob:http://localhost:3000/abc-123";
229+
const instance = mockInstance({
230+
blobToSource: { [blobUrl]: "http://localhost:3000/app.ts" },
231+
});
232+
233+
const stack = ` at ${blobUrl}`;
234+
const result = await rewriteStack(stack, instance);
235+
assert.equal(result, " at http://localhost:3000/app.ts");
236+
});
237+
238+
it("preserves non-blob parts of the stack trace", async () => {
239+
const blobUrl = "blob:http://localhost:3000/abc-123";
240+
const instance = mockInstance({
241+
blobToSource: { [blobUrl]: "http://localhost:3000/app.ts" },
242+
});
243+
244+
const stack = `TypeError: Cannot read property 'foo' of undefined\n at Object.bar (${blobUrl}:5:3)\n at native code`;
245+
const result = await rewriteStack(stack, instance);
246+
assert.ok(result.startsWith("TypeError: Cannot read property 'foo' of undefined"), result);
247+
assert.ok(result.includes("at native code"), result);
248+
});
249+
});

0 commit comments

Comments
 (0)