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..9ea06fba4 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,26 @@ 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('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 290a73990..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; @@ -489,14 +485,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; }