Skip to content

Commit 44f1c5f

Browse files
ankitranjan7claude
andauthored
fix(browser): report the caller's line and column for browser-run compile errors (#356)
* fix(browser): report the caller's line and column for browser-run compile errors A program that fails to compile was surfaced as "QuickJS promise rejected: invalid number literal" with no position, because new AsyncFunction(source) runs inside the promise and its SyntaxError carried lineNumber/columnNumber that were then discarded. The prefix pointed at async plumbing rather than the program, so readers hunted heredocs and await instead of the actual defect: an unescaped quote in a string literal holding a URL. Compile the program before racing it, and turn a compile failure into a BROWSER_RUN_SYNTAX_ERROR carrying the caller's own line and column (the AsyncFunction wrapper adds two lines, so subtract them), a windowed excerpt with a caret, and a hint naming the quoting cause. Errors that already carry a BROWSER_RUN_ code keep their message instead of gaining a host stage prefix. runner.test.ts imported MemorySnapshotBaselineStore twice, so the file failed to load and its 74 tests never ran; the duplicate is removed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(browser): stop reporting runtime SyntaxErrors as caller syntax errors (#358) normalizeExecutionError matched /syntaxerror/i against any error reaching the host, so a runtime JSON.parse failure in a perfectly valid program was reported as BROWSER_RUN_SYNTAX_ERROR with a hint telling the reader to fix correct code. Since compile failures now tag themselves at the point of compilation, the only SyntaxErrors reaching this classifier are runtime ones. Drop the branch: a runtime SyntaxError keeps its own name and message, exactly like the runtime TypeErrors and RangeErrors that already flow through the generic path. Closes #355 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f599e7c commit 44f1c5f

3 files changed

Lines changed: 66 additions & 9 deletions

File tree

src/browser/run/quickjs-host.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,9 @@ export class QuickJSHost {
601601
#toError(prefix: string, errorHandle: QuickJSHandle): Error {
602602
const dumped = this.#context.dump(errorHandle);
603603
const normalized = this.#normalizeHostError(dumped);
604+
// Errors the sandbox raised deliberately already say what went wrong; prefixing
605+
// them with a host-internal stage name only misdirects whoever reads the failure.
606+
if (normalized.code?.startsWith('BROWSER_RUN_')) return normalized;
604607
normalized.message = `${prefix}: ${normalized.message}`;
605608
return normalized;
606609
}

src/browser/run/runner.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ function run(source: string, options = {}, input = {}) {
5555
}, source, options);
5656
}
5757

58+
async function runError(source: string): Promise<Error & { code?: string; hint?: string }> {
59+
try {
60+
await run(source);
61+
} catch (cause) {
62+
return cause as Error & { code?: string; hint?: string };
63+
}
64+
throw new Error('expected the program to fail');
65+
}
66+
5867
describeWithChromium('runBrowserProgram', () => {
5968
beforeAll(async () => {
6069
browser = await chromium.launch({ headless: true });
@@ -231,6 +240,26 @@ afterAll(async () => {
231240

232241
expect(output.result).toBe('undefined');
233242
});
243+
it('reports the caller line, column, and source for a compile error', async () => {
244+
// An unescaped quote ends the string early; the parser trips on what follows.
245+
// Line/column must be the caller's own, not the AsyncFunction wrapper's.
246+
const error = await runError("const a = 1;\nconst s = 'x'y';\nreturn a;");
247+
248+
expect(error.code).toBe('BROWSER_RUN_SYNTAX_ERROR');
249+
expect(error.message).toContain('at line 2, column 14');
250+
expect(error.message).toContain("const s = 'x'y';");
251+
expect(error.message).not.toContain('QuickJS promise rejected');
252+
expect(error.hint).toContain('unescaped quote');
253+
});
254+
255+
it('does not blame the caller syntax for a runtime SyntaxError', async () => {
256+
// JSON.parse throws a SyntaxError from a program that compiled fine.
257+
const error = await runError("return JSON.parse('1,2,3');");
258+
259+
expect(error.code).toBeUndefined();
260+
expect(error.name).toBe('SyntaxError');
261+
expect(error.hint).toBeUndefined();
262+
});
234263
it('publishes the browser-run package subpath', () => {
235264
const packageJson = JSON.parse(
236265
fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),

src/browser/run/runner.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ function normalizeExecutionError(error: unknown): Error {
9393
);
9494
}
9595
const message = error instanceof Error ? error.message : String(error);
96-
const errorKind = error instanceof Error ? error.name : '';
9796
const unsupported = message.match(/BROWSER_RUN_API_UNSUPPORTED:\s*(.*)/s)
9897
?? message.match(/(File paths? are unavailable in the QuickJS sandbox[^.]*)/i);
9998
if (unsupported) {
@@ -115,13 +114,10 @@ function normalizeExecutionError(error: unknown): Error {
115114
'Browser-run execution exceeded its memory limit.',
116115
);
117116
}
118-
if (/syntaxerror/i.test(`${errorKind}: ${message}`)) {
119-
return new BrowserRunError(
120-
'BROWSER_RUN_SYNTAX_ERROR',
121-
sanitize(message),
122-
'Fix the browser-run JavaScript syntax and retry.',
123-
);
124-
}
117+
// A compile failure of the caller's program is tagged BROWSER_RUN_SYNTAX_ERROR where it
118+
// happens and returned by the code.startsWith('BROWSER_RUN_') branch above. Every
119+
// SyntaxError reaching here is therefore a runtime one — JSON.parse on malformed input is
120+
// the common case — so it keeps its own name and message like any other runtime error.
125121
const normalized = new Error(sanitize(message));
126122
normalized.name = error instanceof Error ? error.name : 'Error';
127123
return normalized;
@@ -489,14 +485,43 @@ export async function runBrowserProgram(
489485
connection.close(message);
490486
rejectRun?.(new Error(message));
491487
};
488+
globalThis.__webcmdCompileError = (source, cause) => {
489+
// new AsyncFunction(body) prepends two lines of wrapper, so QuickJS reports
490+
// a line two ahead of the one the caller actually wrote.
491+
const lines = source.split('\\n');
492+
const line = (cause?.lineNumber ?? 0) - 2;
493+
const column = cause?.columnNumber ?? 0;
494+
const text = line >= 1 && line <= lines.length ? lines[line - 1] : undefined;
495+
let excerpt = '';
496+
if (text !== undefined) {
497+
const start = Math.max(0, column - 60);
498+
const caret = ' '.repeat(Math.max(0, column - 1 - start)) + '^';
499+
excerpt = '\\n ' + text.slice(start, column + 20) + '\\n ' + caret;
500+
}
501+
const where = line >= 1 ? ' at line ' + line + ', column ' + column : '';
502+
const message = cause?.message ?? 'Program failed to compile.';
503+
const error = new SyntaxError(message + where + excerpt);
504+
error.code = 'BROWSER_RUN_SYNTAX_ERROR';
505+
error.hint = 'Your program failed to compile; the browser was never touched. '
506+
+ 'A common cause is an unescaped quote when embedding a URL or HTML in a '
507+
+ 'string literal — use double quotes or a template literal for values that '
508+
+ 'contain apostrophes.';
509+
return error;
510+
};
492511
globalThis.__webcmdRun = async source => {
493512
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
513+
let program;
514+
try {
515+
program = new AsyncFunction(source);
516+
} catch (cause) {
517+
throw __webcmdCompileError(source, cause);
518+
}
494519
let value;
495520
try {
496521
const cancellation = new Promise((_resolve, reject) => {
497522
rejectRun = reject;
498523
});
499-
value = await Promise.race([new AsyncFunction(source)(), cancellation]);
524+
value = await Promise.race([program(), cancellation]);
500525
} finally {
501526
rejectRun = undefined;
502527
}

0 commit comments

Comments
 (0)