diff --git a/src/jsc/bindings/ErrorStackFrame.cpp b/src/jsc/bindings/ErrorStackFrame.cpp index 806a340be246..abd131c1f92d 100644 --- a/src/jsc/bindings/ErrorStackFrame.cpp +++ b/src/jsc/bindings/ErrorStackFrame.cpp @@ -1,6 +1,7 @@ #include "root.h" #include "JavaScriptCore/CodeBlock.h" #include "headers-handwritten.h" +#include "NodeVMScriptFetcher.h" #include "JavaScriptCore/BytecodeIndex.h" #include "wtf/Assertions.h" #include "wtf/text/OrdinalNumber.h" @@ -95,6 +96,11 @@ ZigStackFramePosition getAdjustedPositionForBytecode(JSC::CodeBlock* code, JSC:: break; } + if (auto* provider = code->source().provider()) { + if (unsigned wrapperColumns = NodeVMScriptFetcher::wrapperColumnsOnLine(*provider, pos.line_zero_based)) + pos.column_zero_based = static_cast(std::max(static_cast(pos.column_zero_based) - wrapperColumns, 0)); + } + return pos; } diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index d622679f31aa..ccc0ae9bceda 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -22,6 +22,7 @@ #include "BunClientData.h" #include "CallSite.h" #include "ErrorStackTrace.h" +#include "NodeVMScriptFetcher.h" #include "headers-handwritten.h" #include @@ -265,6 +266,13 @@ WTF::String formatStackTrace( if (!frame.hasLineAndColumnInfo()) continue; originalLineColumns[i] = frame.computeLineAndColumn(); + if (auto* codeBlock = frame.codeBlock()) { + if (auto* provider = codeBlock->source().provider()) { + LineColumn& lineColumn = originalLineColumns[i]; + if (unsigned wrapperColumns = NodeVMScriptFetcher::wrapperColumnsOnLine(*provider, static_cast(lineColumn.line) - 1)) + lineColumn.column = lineColumn.column > wrapperColumns ? lineColumn.column - wrapperColumns : 1; + } + } JSC::JSGlobalObject* globalObjectForFrame = lexicalGlobalObject; if (auto* callee = frame.callee()) { diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 54a3d25d2947..0176962708cb 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -183,23 +183,23 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c } // wrap the arguments in an anonymous function expression - int startOffset = 0; - String code = stringifyAnonymousFunction(globalObject, args, throwScope, &startOffset); + int wrapperPrefixLength = 0; + String code = stringifyAnonymousFunction(globalObject, args, throwScope, &wrapperPrefixLength); EXCEPTION_ASSERT(!!throwScope.exception() == code.isNull()); + RETURN_IF_EXCEPTION(throwScope, nullptr); - // The user's body starts on line 2 of the wrapped program (after the - // "(function () {\n" prefix). Shift the provider's start position up one - // line so reported positions line up with the body the way V8's - // CompileFunction does: body line 1 reports as lineOffset+1. JSC clamps - // non-positive provider start positions to zero, so once the input is - // already <= 0 there is nothing to gain by going further negative; clamp - // there to keep the value bounded for downstream arithmetic. - int lineZeroBased = position.m_line.zeroBasedInt(); - TextPosition wrappedPosition(OrdinalNumber::fromZeroBasedInt(lineZeroBased > 0 ? lineZeroBased - 1 : lineZeroBased), position.m_column); + // JSC counts the wrapper in line 1's columns and cannot start a source at a negative column: apply what exceeds the wrapper here, take the rest off when positions are reported. + int columnOffset = position.m_column.zeroBasedInt(); + int appliedColumnOffset = columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0; + unsigned wrapperColumns = static_cast(static_cast(wrapperPrefixLength) + appliedColumnOffset - columnOffset); + TextPosition wrappedPosition(position.m_line, OrdinalNumber::fromZeroBasedInt(appliedColumnOffset)); - SourceCode sourceCode( - JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program), - wrappedPosition.m_line.oneBasedInt(), wrappedPosition.m_column.oneBasedInt()); + Ref provider = JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program); + + if (auto* fetcher = sourceOrigin.fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM) + static_cast(fetcher)->setWrapper(provider.get(), static_cast(wrapperPrefixLength), wrapperColumns); + + SourceCode sourceCode(WTF::move(provider), wrappedPosition.m_line.oneBasedInt(), wrappedPosition.m_column.oneBasedInt()); CodeCache* cache = vm.codeCache(); ProgramExecutable* programExecutable = ProgramExecutable::create(globalObject, sourceCode); @@ -380,21 +380,22 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu RELEASE_AND_RETURN(scope, JSPromise::resolvedPromise(globalObject, thenResult)); } -// Helper function to create an anonymous function expression with parameters +// The body deliberately shares the wrapper's line: JSC clamps a source's first line to 1, so a wrapper line could not be compensated for at lineOffset 0. String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& args, ThrowScope& scope, int* outOffset) { // How we stringify functions is important for creating anonymous function expressions String program; if (args.isEmpty()) { // No arguments, just an empty function body - program = "(function () {\n\n})"_s; + program = "(function () {\n})"_s; + *outOffset = "(function () {"_s.length(); } else if (args.size() == 1) { // Just the function body auto body = args.at(0).toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - program = tryMakeString("(function () {\n"_s, body, "\n})"_s); - *outOffset = "(function () {\n"_s.length(); + program = tryMakeString("(function () {"_s, body, "\n})"_s); + *outOffset = "(function () {"_s.length(); if (!program) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); @@ -419,8 +420,8 @@ String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& a auto body = args.at(parameterCount).toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - program = tryMakeString("(function ("_s, paramString.toString(), ") {\n"_s, body, "\n})"_s); - *outOffset = "(function ("_s.length() + paramString.length() + ") {\n"_s.length(); + program = tryMakeString("(function ("_s, paramString.toString(), ") {"_s, body, "\n})"_s); + *outOffset = "(function ("_s.length() + paramString.length() + ") {"_s.length(); if (!program) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); @@ -490,7 +491,8 @@ JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, const JSC::So // AppendExceptionLine helpers shared by the runtime (handleException) and // compile-time (decorateParseErrorStack) paths — a single implementation of // Node's arrow-header format so the two call sites cannot drift. -static String nthSourceLineForArrowHeader(StringView source, int64_t physicalLine1Based) +// firstLineSkip: compileFunction's wrapper text in front of the user's first line. +static String nthSourceLineForArrowHeader(StringView source, int64_t physicalLine1Based, unsigned firstLineSkip = 0) { if (physicalLine1Based < 1 || physicalLine1Based > static_cast(source.length()) + 1) return {}; @@ -505,6 +507,8 @@ static String nthSourceLineForArrowHeader(StringView source, int64_t physicalLin if (lineEnd == WTF::notFound) lineEnd = source.length(); StringView lineView = source.substring(lineStart, lineEnd - lineStart); + if (physicalLine1Based == 1) + lineView = lineView.substring(firstLineSkip); if (lineView.endsWith('\r')) lineView = lineView.left(lineView.length() - 1); // Like Node, skip the decoration for excessively long lines. @@ -574,14 +578,19 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtrsource().provider()) { + unsigned wrapperPrefixLength = NodeVMScriptFetcher::wrapperTextLength(*provider); + int64_t startLineZeroBased = provider->startPosition().m_line.zeroBasedInt(); int64_t physicalLine = static_cast(line_and_column.line) - startLineZeroBased; - sourceLineText = nthSourceLineForArrowHeader(provider->source(), physicalLine); + sourceLineText = nthSourceLineForArrowHeader(provider->source(), physicalLine, wrapperPrefixLength); if (!sourceLineText.isNull()) { caretColumn = line_and_column.column; - unsigned startColumnZeroBased = static_cast(provider->startPosition().m_column.zeroBasedInt()); - if (physicalLine == 1 && caretColumn > startColumnZeroBased) - caretColumn -= startColumnZeroBased; + if (physicalLine == 1) { + // SourceCode clamps a negative start column to 0, so that is what the frame's column includes. + unsigned startColumnZeroBased = static_cast(std::max(0, provider->startPosition().m_column.zeroBasedInt())); + unsigned firstLineShift = startColumnZeroBased + wrapperPrefixLength; + caretColumn = caretColumn > firstLineShift ? caretColumn - firstLineShift : 0; + } } } } diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h index 7275d906f5a4..40c91256bba6 100644 --- a/src/jsc/bindings/NodeVMScriptFetcher.h +++ b/src/jsc/bindings/NodeVMScriptFetcher.h @@ -3,6 +3,7 @@ #include "root.h" #include +#include #include #include #include @@ -41,7 +42,45 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher { }); } + // The compileFunction wrapper (see stringifyAnonymousFunction) shares the program's first line with the body. + void setWrapper(JSC::SourceProvider& program, unsigned textLength, unsigned columnsBeyondNode) + { + m_wrapperSourceID = program.asID(); + m_wrapperTextLength = textLength; + m_wrapperColumns = columnsBeyondNode; + } + + // Wrapper text starting the first line; 0 unless `provider` is a compileFunction program. + static unsigned wrapperTextLength(JSC::SourceProvider& provider) + { + auto* fetcher = wrapperFetcherFor(provider); + return fetcher ? fetcher->m_wrapperTextLength : 0; + } + + // By how much JSC's columns exceed Node's on this line; 0 unless it is a compileFunction program's first line. + static unsigned wrapperColumnsOnLine(JSC::SourceProvider& provider, int lineZeroBased) + { + auto* fetcher = wrapperFetcherFor(provider); + if (!fetcher) + return 0; + // SourceCode clamps the start line to the first line, so JSC reports the first physical line as max(lineOffset, 0). + int firstLine = std::max(0, provider.startPosition().m_line.zeroBasedInt()); + return lineZeroBased == firstLine ? fetcher->m_wrapperColumns : 0; + } + private: + // Matched by provider: eval() and new Function() code inside the body inherits this fetcher without the wrapper. + static NodeVMScriptFetcher* wrapperFetcherFor(JSC::SourceProvider& provider) + { + auto* fetcher = provider.sourceOrigin().fetcher(); + if (!fetcher || fetcher->fetcherType() != Type::NodeVM) + return nullptr; + auto* vmFetcher = static_cast(fetcher); + if (!vmFetcher->m_wrapperTextLength || provider.asID() != vmFetcher->m_wrapperSourceID) + return nullptr; + return vmFetcher; + } + JSC::Strong m_dynamicImportCallback; // m_owner is the NodeVMScript / JSFunction / module wrapper that holds this // fetcher via m_source -> SourceProvider -> SourceOrigin -> RefPtr. @@ -50,6 +89,9 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher { // as a GC root). Use Weak instead: when the owner is collected its // SourceCode chain drops the last RefPtr to this fetcher. JSC::Weak m_owner; + JSC::SourceID m_wrapperSourceID = 0; + unsigned m_wrapperTextLength = 0; + unsigned m_wrapperColumns = 0; bool m_isUsingDefaultLoader = false; NodeVMScriptFetcher(JSC::VM& vm, JSC::JSValue dynamicImportCallback, JSC::JSValue owner) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..2a147b394aa6 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -43,6 +43,7 @@ #include "ErrorStackFrame.h" #include "ErrorStackTrace.h" +#include "NodeVMScriptFetcher.h" #include "ObjectBindings.h" #include @@ -161,6 +162,9 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr return; if (source_lines_count > 1 && source_lines != nullptr && sourceString.is8Bit()) { + // vm.compileFunction's wrapper starts the first line; it is not part of the user's source. + unsigned wrapperTextLength = Bun::NodeVMScriptFetcher::wrapperTextLength(*provider); + // Search for the beginning of the line unsigned int lineStart = location.byte_position; while (lineStart > 0 && sourceString[lineStart] != '\n') { @@ -185,7 +189,8 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr (*referenced_source_provider)->deref(); } *referenced_source_provider = provider; - source_lines[0] = Bun::toStringView(sourceString.substring(lineStart, lineEnd - lineStart)); + unsigned int textStart = lineStart == 0 ? std::min(wrapperTextLength, lineEnd) : lineStart; + source_lines[0] = Bun::toStringView(sourceString.substring(textStart, lineEnd - textStart)); source_line_numbers[0] = location.line(); if (lineStart > 0) { @@ -211,7 +216,8 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr } // We are at the beginning of the line - source_lines[source_line_i] = Bun::toStringView(sourceString.substring(byte_offset_in_source_string, end_of_line_offset - byte_offset_in_source_string + 1)); + unsigned int contextStart = byte_offset_in_source_string == 0 ? std::min(wrapperTextLength, end_of_line_offset + 1) : byte_offset_in_source_string; + source_lines[source_line_i] = Bun::toStringView(sourceString.substring(contextStart, end_of_line_offset + 1 - contextStart)); source_line_numbers[source_line_i] = location.line().fromZeroBasedInt(location.line().zeroBasedInt() - source_line_i); source_line_i++; diff --git a/test/js/node/test/parallel/test-vm-basic.js b/test/js/node/test/parallel/test-vm-basic.js index be409b937246..bee21b70daec 100644 --- a/test/js/node/test/parallel/test-vm-basic.js +++ b/test/js/node/test/parallel/test-vm-basic.js @@ -131,9 +131,14 @@ const vm = require('vm'); // vm.compileFunction { + // Bun compiles the body on the same line as the wrapper (the engine cannot + // start a source at line 0, so a wrapper line would shift every body line), + // and toString() returns the text as compiled. assert.strictEqual( vm.compileFunction('console.log("Hello, World!")').toString(), - 'function () {\nconsole.log("Hello, World!")\n}' + typeof Bun === 'undefined' + ? 'function () {\nconsole.log("Hello, World!")\n}' + : 'function () {console.log("Hello, World!")\n}' ); assert.strictEqual( @@ -277,17 +282,17 @@ const vm = require('vm'); // Setting value to run the last three tests Error.stackTraceLimit = 1; - // Bun's compileFunction stack frames differ from Node's: JSC attributes - // the throw to a different column (and columnOffset is not applied), the - // wrapper costs one line when lineOffset is 0, and the anonymous source is - // labeled differently. + // Bun's compileFunction stack frames differ from Node's in two ways: JSC + // attributes the throw to the call's `(` where V8 uses the `new` keyword + // (hence column 16 where Node has 7), and the anonymous source is labeled + // differently. Lines and offsets match Node. assert.throws(() => { vm.compileFunction('throw new Error("Sample Error")')(); }, { message: 'Sample Error', stack: typeof Bun === 'undefined' ? 'Error: Sample Error\n at :1:7' - : 'Error: Sample Error\n at (file:///:2:16)' + : 'Error: Sample Error\n at (file:///:1:16)' }); assert.throws(() => { @@ -313,7 +318,7 @@ const vm = require('vm'); message: 'Sample Error', stack: typeof Bun === 'undefined' ? 'Error: Sample Error\n at :1:10' - : 'Error: Sample Error\n at (file:///:2:16)' + : 'Error: Sample Error\n at (file:///:1:19)' }); assert.strictEqual( @@ -337,7 +342,7 @@ const vm = require('vm'); // Bun's compileFunction stack frames differ from Node's (see above). stack: typeof Bun === 'undefined' ? 'ReferenceError: varInContext is not defined\n at :1:1' - : 'ReferenceError: varInContext is not defined\n at (file:///:2:20)' + : 'ReferenceError: varInContext is not defined\n at (file:///:1:20)' }); assert.notDeepStrictEqual( diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index d3d04239d179..b9ff3f4b8a3b 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -269,6 +269,243 @@ describe("vm", () => { expect(e).toBeTruthy(); } }); + + // Positions of runtime errors thrown by the compiled function. Node + // reports body line N as lineOffset + N; the body is line 1, not the + // line after a wrapper. + function thrownPosition(filename: string, fn: () => unknown) { + let stack: string = ""; + try { + fn(); + } catch (e: any) { + stack = e.stack; + } + const match = stack.match(new RegExp(`${filename.replaceAll(".", "\\.")}:(\\d+):(\\d+)`)); + if (!match) throw new Error(`no ${filename} frame in:\n${stack}`); + return { line: Number(match[1]), column: Number(match[2]) }; + } + + test("runtime errors report body line N as lineOffset + N", () => { + const filename = "cf-lines.js"; + const results: unknown[] = []; + const expected: unknown[] = []; + for (const lineOffset of [undefined, 0, 1, 7]) { + const options = lineOffset === undefined ? { filename } : { filename, lineOffset }; + const base = lineOffset ?? 0; + results.push({ + lineOffset, + line1: thrownPosition(filename, compileFunction('throw new Error("line 1")', [], options)).line, + line3: thrownPosition(filename, compileFunction('1;\n2;\nthrow new Error("line 3")', [], options)).line, + line1WithParams: thrownPosition(filename, () => + compileFunction("throw new Error(String(a + b))", ["a", "b"], options)(1, 2), + ).line, + // Functions nested in the body are positioned relative to the same origin. + nestedArrowOnLine1: thrownPosition( + filename, + compileFunction('return () => { throw new Error("arrow") };', [], options)(), + ).line, + nestedFunctionOnLine2: thrownPosition( + filename, + compileFunction('return function inner() {\n throw new Error("inner");\n};', [], options)(), + ).line, + }); + expected.push({ + lineOffset, + line1: base + 1, + line3: base + 3, + line1WithParams: base + 1, + nestedArrowOnLine1: base + 1, + nestedFunctionOnLine2: base + 2, + }); + } + expect(results).toEqual(expected); + }); + + test("Error.prepareStackTrace call sites see the same body lines", () => { + const previous = Error.prepareStackTrace; + Error.prepareStackTrace = (_, callSites) => + callSites.map(site => `${site.getFileName()}:${site.getLineNumber()}`); + try { + const fn = compileFunction("return [new Error('a').stack[0], (() => new Error('b').stack[0])()];", [], { + filename: "cf-callsites.js", + }); + expect(fn()).toEqual(["cf-callsites.js:1", "cf-callsites.js:1"]); + const offsetFn = compileFunction("\nreturn new Error('c').stack[0];", [], { + filename: "cf-callsites.js", + lineOffset: 10, + }); + expect(offsetFn()).toBe("cf-callsites.js:12"); + } finally { + Error.prepareStackTrace = previous; + } + }); + + test("body line 1 reports the body's own columns, plus columnOffset", () => { + const filename = "cf-columns.js"; + const statement = 'throw new Error("column")'; + // The column JSC assigns to this statement when it starts a line that no + // wrapper or offset touches. + const { column } = thrownPosition(filename, compileFunction("\n" + statement, [], { filename })); + const results = { + line1: thrownPosition(filename, compileFunction(statement, [], { filename })), + line1WithParams: thrownPosition(filename, () => compileFunction(statement, ["a", "b"], { filename })()), + // Node adds columnOffset on the first line only, whatever its size. + line1Offset3: thrownPosition(filename, compileFunction(statement, [], { filename, columnOffset: 3 })), + line1Offset100: thrownPosition(filename, compileFunction(statement, [], { filename, columnOffset: 100 })), + line1WithParamsOffset100: thrownPosition(filename, () => + compileFunction(statement, ["a", "b"], { filename, columnOffset: 100 })(), + ), + line2Offset100: thrownPosition( + filename, + compileFunction("\n" + statement, [], { filename, columnOffset: 100 }), + ), + nestedArrowOnLine1: thrownPosition( + filename, + compileFunction(`return () => { ${statement} };`, [], { filename })(), + ), + nestedArrowOnLine2: thrownPosition( + filename, + compileFunction(`\nreturn () => { ${statement} };`, [], { filename })(), + ), + }; + expect(results).toEqual({ + line1: { line: 1, column }, + line1WithParams: { line: 1, column }, + line1Offset3: { line: 1, column: column + 3 }, + line1Offset100: { line: 1, column: column + 100 }, + line1WithParamsOffset100: { line: 1, column: column + 100 }, + line2Offset100: { line: 2, column }, + nestedArrowOnLine1: { line: 1, column: results.nestedArrowOnLine2.column }, + nestedArrowOnLine2: { line: 2, column: results.nestedArrowOnLine2.column }, + }); + }); + + test("Error.prepareStackTrace call sites report the same columns on body line 1", () => { + const previous = Error.prepareStackTrace; + Error.prepareStackTrace = (_, callSites) => callSites.map(site => [site.getLineNumber(), site.getColumnNumber()]); + try { + const body = "return new Error('x').stack[0];"; + const [, column] = compileFunction("\n" + body, [], { filename: "cf-callsite-columns.js" })(); + expect({ + line1: compileFunction(body, [], { filename: "cf-callsite-columns.js" })(), + line1WithParams: compileFunction(body, ["a"], { filename: "cf-callsite-columns.js" })(), + line1Offset5: compileFunction(body, [], { filename: "cf-callsite-columns.js", columnOffset: 5 })(), + }).toEqual({ + line1: [1, column], + line1WithParams: [1, column], + line1Offset5: [1, column + 5], + }); + } finally { + Error.prepareStackTrace = previous; + } + }); + + test.concurrent("uncaught error output shows the body line without the wrapper", async () => { + const run = async (code: string) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + // The source excerpt: line-numbered source lines followed by the caret line. + excerpt: stderr.split("\n").filter(line => /^\s*\d+ \| /.test(line) || /^\s*\^$/.test(line)), + stdout, + exitCode, + }; + }; + const body = JSON.stringify('let ok = 1; throw new Error("boom")'); + const [fromScript, fromFunction, fromOffsetFunction] = await Promise.all([ + // Reference: the same text run as a script, which has no wrapper. + run( + `new (require("node:vm").Script)(${body}, { filename: "/virtual/ref.js" }).runInThisContext({ displayErrors: false })`, + ), + run(`require("node:vm").compileFunction(${body}, ["exports", "require"], { filename: "/virtual/cf.js" })()`), + run(`require("node:vm").compileFunction(${body}, [], { filename: "/virtual/cf.js", lineOffset: 10 })()`), + ]); + expect(fromScript).toEqual({ + excerpt: [`1 | ${JSON.parse(body)}`, expect.stringMatching(/^ +\^$/)], + stdout: "", + exitCode: 1, + }); + expect(fromFunction).toEqual(fromScript); + // A wider line number indents the caret one more column. + expect(fromOffsetFunction).toEqual({ + excerpt: [`11 | ${JSON.parse(body)}`, " " + fromScript.excerpt[1]], + stdout: "", + exitCode: 1, + }); + }); + + test("runtime arrow header shows the body line when called from a vm script", () => { + // Node decorates an error escaping a vm run with `:`, the + // offending source line and a caret. For a function from compileFunction + // the line and source text are those of the body, not of the wrapper. + const header = (fn: () => unknown) => { + let stack: string = ""; + try { + fn(); + } catch (e: any) { + stack = e.stack; + } + return stack.split("\n").slice(0, 4); + }; + const line1 = 'throw new Error("line 1")'; + const line2 = '0;\n throw new Error("line 2")'; + + // Reference: the same statements compiled as a script, where no wrapper exists. + const [, , scriptCaret] = header(() => new Script(line1, { filename: "ref.js" }).runInThisContext()); + expect(scriptCaret).toMatch(/^ *\^$/); + const [, , scriptCaret2] = header(() => new Script(line2, { filename: "ref.js" }).runInThisContext()); + expect(scriptCaret2).toMatch(/^ +\^$/); + + const callFromScript = (fn: Function) => () => + new Script("fn()", { filename: "outer.js" }).runInNewContext({ fn }); + + expect(header(callFromScript(compileFunction(line1, [], { filename: "cf-header.js" })))).toEqual([ + "cf-header.js:1", + line1, + scriptCaret, + "", + ]); + expect(header(callFromScript(compileFunction(line1, ["a", "b"], { filename: "cf-header.js" })))).toEqual([ + "cf-header.js:1", + line1, + scriptCaret, + "", + ]); + expect( + header(callFromScript(compileFunction(line1, [], { filename: "cf-header.js", columnOffset: 100 }))), + ).toEqual(["cf-header.js:1", line1, scriptCaret, ""]); + expect(header(callFromScript(compileFunction(line2, [], { filename: "cf-header.js" })))).toEqual([ + "cf-header.js:2", + ' throw new Error("line 2")', + scriptCaret2, + "", + ]); + expect(header(callFromScript(compileFunction(line1, [], { filename: "cf-header.js", lineOffset: 4 })))).toEqual([ + "cf-header.js:5", + line1, + scriptCaret, + "", + ]); + + // Code eval'd from inside the body gets its own source, which has no + // wrapper, even though it inherits the function's origin. + for (const evalCall of ["eval", "(0, eval)"]) { + const [headerLine, sourceLine, caret] = header( + callFromScript(compileFunction(`${evalCall}(${JSON.stringify(line1)})`, [], { filename: "cf-header.js" })), + ); + expect({ evalCall, headerLine: headerLine.replace(/^.*:/, ":"), sourceLine, caret }).toEqual({ + evalCall, + headerLine: ":1", + sourceLine: line1, + caret: scriptCaret, + }); + } + }); }); });