From 3997e54703223e0954cbc0d1f08ad42306463218 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 19 Aug 2026 02:37:43 +0530 Subject: [PATCH 1/2] 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 --- src/browser/run/quickjs-host.ts | 3 +++ src/browser/run/runner.test.ts | 21 ++++++++++++++++++++- src/browser/run/runner.ts | 31 ++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/browser/run/quickjs-host.ts b/src/browser/run/quickjs-host.ts index 2bc1ff1fd..82569ce6d 100644 --- a/src/browser/run/quickjs-host.ts +++ b/src/browser/run/quickjs-host.ts @@ -601,6 +601,9 @@ export class QuickJSHost { #toError(prefix: string, errorHandle: QuickJSHandle): Error { const dumped = this.#context.dump(errorHandle); const normalized = this.#normalizeHostError(dumped); + // Errors the sandbox raised deliberately already say what went wrong; prefixing + // them with a host-internal stage name only misdirects whoever reads the failure. + if (normalized.code?.startsWith('BROWSER_RUN_')) return normalized; normalized.message = `${prefix}: ${normalized.message}`; return normalized; } diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index a35796e97..9cca84c75 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -20,7 +20,6 @@ import { } from 'playwright-core'; import { LocalBrowserRunArtifactSink } from './artifacts.js'; import { MemorySnapshotBaselineStore } from '../snapshot/index.js'; -import { MemorySnapshotBaselineStore } from '../snapshot/index.js'; import { unsupportedApiMessage } from './playwright-transport.js'; import { QuickJSHost } from './quickjs-host.js'; import { runBrowserProgram } from './runner.js'; @@ -56,6 +55,15 @@ function run(source: string, options = {}, input = {}) { }, source, options); } +async function runError(source: string): Promise { + try { + await run(source); + } catch (cause) { + return cause as Error & { code?: string; hint?: string }; + } + throw new Error('expected the program to fail'); +} + describeWithChromium('runBrowserProgram', () => { beforeAll(async () => { browser = await chromium.launch({ headless: true }); @@ -232,6 +240,17 @@ afterAll(async () => { expect(output.result).toBe('undefined'); }); + it('reports the caller line, column, and source for a compile error', async () => { + // An unescaped quote ends the string early; the parser trips on what follows. + // Line/column must be the caller's own, not the AsyncFunction wrapper's. + const error = await runError("const a = 1;\nconst s = 'x'y';\nreturn a;"); + + expect(error.code).toBe('BROWSER_RUN_SYNTAX_ERROR'); + expect(error.message).toContain('at line 2, column 14'); + expect(error.message).toContain("const s = 'x'y';"); + expect(error.message).not.toContain('QuickJS promise rejected'); + expect(error.hint).toContain('unescaped quote'); + }); it('publishes the browser-run package subpath', () => { const packageJson = JSON.parse( fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index 290a73990..a5565438e 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -489,14 +489,43 @@ export async function runBrowserProgram( connection.close(message); rejectRun?.(new Error(message)); }; + globalThis.__webcmdCompileError = (source, cause) => { + // new AsyncFunction(body) prepends two lines of wrapper, so QuickJS reports + // a line two ahead of the one the caller actually wrote. + const lines = source.split('\\n'); + const line = (cause?.lineNumber ?? 0) - 2; + const column = cause?.columnNumber ?? 0; + const text = line >= 1 && line <= lines.length ? lines[line - 1] : undefined; + let excerpt = ''; + if (text !== undefined) { + const start = Math.max(0, column - 60); + const caret = ' '.repeat(Math.max(0, column - 1 - start)) + '^'; + excerpt = '\\n ' + text.slice(start, column + 20) + '\\n ' + caret; + } + const where = line >= 1 ? ' at line ' + line + ', column ' + column : ''; + const message = cause?.message ?? 'Program failed to compile.'; + const error = new SyntaxError(message + where + excerpt); + error.code = 'BROWSER_RUN_SYNTAX_ERROR'; + error.hint = 'Your program failed to compile; the browser was never touched. ' + + 'A common cause is an unescaped quote when embedding a URL or HTML in a ' + + 'string literal — use double quotes or a template literal for values that ' + + 'contain apostrophes.'; + return error; + }; globalThis.__webcmdRun = async source => { const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + let program; + try { + program = new AsyncFunction(source); + } catch (cause) { + throw __webcmdCompileError(source, cause); + } let value; try { const cancellation = new Promise((_resolve, reject) => { rejectRun = reject; }); - value = await Promise.race([new AsyncFunction(source)(), cancellation]); + value = await Promise.race([program(), cancellation]); } finally { rejectRun = undefined; } From 5d1b08fb1b70c668536bbebe7407c85f44a8e9ab Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 19 Aug 2026 17:09:06 +0530 Subject: [PATCH 2/2] 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 --- src/browser/run/runner.test.ts | 9 +++++++++ src/browser/run/runner.ts | 12 ++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index 9cca84c75..9ea06fba4 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -251,6 +251,15 @@ afterAll(async () => { expect(error.message).not.toContain('QuickJS promise rejected'); expect(error.hint).toContain('unescaped quote'); }); + + it('does not blame the caller syntax for a runtime SyntaxError', async () => { + // JSON.parse throws a SyntaxError from a program that compiled fine. + const error = await runError("return JSON.parse('1,2,3');"); + + expect(error.code).toBeUndefined(); + expect(error.name).toBe('SyntaxError'); + expect(error.hint).toBeUndefined(); + }); it('publishes the browser-run package subpath', () => { const packageJson = JSON.parse( fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index a5565438e..b6d8432dd 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -93,7 +93,6 @@ function normalizeExecutionError(error: unknown): Error { ); } const message = error instanceof Error ? error.message : String(error); - const errorKind = error instanceof Error ? error.name : ''; const unsupported = message.match(/BROWSER_RUN_API_UNSUPPORTED:\s*(.*)/s) ?? message.match(/(File paths? are unavailable in the QuickJS sandbox[^.]*)/i); if (unsupported) { @@ -115,13 +114,10 @@ function normalizeExecutionError(error: unknown): Error { 'Browser-run execution exceeded its memory limit.', ); } - if (/syntaxerror/i.test(`${errorKind}: ${message}`)) { - return new BrowserRunError( - 'BROWSER_RUN_SYNTAX_ERROR', - sanitize(message), - 'Fix the browser-run JavaScript syntax and retry.', - ); - } + // A compile failure of the caller's program is tagged BROWSER_RUN_SYNTAX_ERROR where it + // happens and returned by the code.startsWith('BROWSER_RUN_') branch above. Every + // SyntaxError reaching here is therefore a runtime one — JSON.parse on malformed input is + // the common case — so it keeps its own name and message like any other runtime error. const normalized = new Error(sanitize(message)); normalized.name = error instanceof Error ? error.name : 'Error'; return normalized;