Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/browser/run/quickjs-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
30 changes: 29 additions & 1 deletion src/browser/run/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,6 +55,15 @@ function run(source: string, options = {}, input = {}) {
}, source, options);
}

async function runError(source: string): Promise<Error & { code?: string; hint?: string }> {
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 });
Expand Down Expand Up @@ -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'),
Expand Down
43 changes: 34 additions & 9 deletions src/browser/run/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading