diff --git a/src/jsc/ZigStackFrame.rs b/src/jsc/ZigStackFrame.rs index 4de668b7810d..3eb9adc4804b 100644 --- a/src/jsc/ZigStackFrame.rs +++ b/src/jsc/ZigStackFrame.rs @@ -173,7 +173,7 @@ impl<'a> fmt::Display for SourceURLFormatter<'a> { if self.line_column == LineColumn::Include && !source_slice.is_empty() - && (self.position.line.is_valid() || self.position.column.is_valid()) + && self.position.line.is_valid() { if self.enable_color { f.write_str(Output::pretty_fmt!(":", true))?; diff --git a/src/jsc/bindings/ErrorStackFrame.cpp b/src/jsc/bindings/ErrorStackFrame.cpp index 806a340be246..746a2fdfb363 100644 --- a/src/jsc/bindings/ErrorStackFrame.cpp +++ b/src/jsc/bindings/ErrorStackFrame.cpp @@ -1,13 +1,31 @@ #include "root.h" +#include "ErrorStackFrame.h" #include "JavaScriptCore/CodeBlock.h" -#include "headers-handwritten.h" -#include "JavaScriptCore/BytecodeIndex.h" +#include "JavaScriptCore/StackFrame.h" #include "wtf/Assertions.h" #include "wtf/text/OrdinalNumber.h" namespace Bun { using namespace JSC; +void applyNegativeSourceStart(CodeBlock* code, int& lineZeroBased, int& columnZeroBased) +{ + auto* provider = code->source().provider(); + if (!provider) + return; + + // Nested functions share the program's provider, so this is the whole source's start. + TextPosition start = provider->startPosition(); + int startLine = start.m_line.zeroBasedInt(); + int startColumn = start.m_column.zeroBasedInt(); + + // JSC applies the start column to the first physical line only, reported as the clamped start line. + if (startColumn < 0 && lineZeroBased == std::max(startLine, 0)) + columnZeroBased += startColumn; + if (startLine < 0) + lineZeroBased += startLine; +} + /// Adjust a `ZigStackFramePosition` by a number of bytes. This accounts for when the adjustment /// crosses line boundaries, and thus requires the source code in order to properly compute /// the result. @@ -95,6 +113,20 @@ ZigStackFramePosition getAdjustedPositionForBytecode(JSC::CodeBlock* code, JSC:: break; } + // After the walk: it works in JSC's coordinates and may end on a different line. + applyNegativeSourceStart(code, pos.line_zero_based, pos.column_zero_based); + return pos; +} + +ZigStackFramePosition getLineColumnForStackFrame(const StackFrame& frame) +{ + auto lineColumn = frame.computeLineAndColumn(); + ZigStackFramePosition pos { + .line_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.line).zeroBasedInt(), + .column_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.column).zeroBasedInt(), + .byte_position = -1, + }; + applyNegativeSourceStart(frame.codeBlock(), pos.line_zero_based, pos.column_zero_based); return pos; } diff --git a/src/jsc/bindings/ErrorStackFrame.h b/src/jsc/bindings/ErrorStackFrame.h index d7a04e83d6cd..74ce7582915a 100644 --- a/src/jsc/bindings/ErrorStackFrame.h +++ b/src/jsc/bindings/ErrorStackFrame.h @@ -1,9 +1,22 @@ +#pragma once + #include "root.h" #include "headers-handwritten.h" #include "JavaScriptCore/BytecodeIndex.h" +namespace JSC { +class CodeBlock; +class StackFrame; +} + namespace Bun { ZigStackFramePosition getAdjustedPositionForBytecode(JSC::CodeBlock* code, JSC::BytecodeIndex bc); +/// Re-adds the part of a negative node:vm lineOffset / columnOffset that JSC::SourceCode clamps away; no-op for other sources. +void applyNegativeSourceStart(JSC::CodeBlock* code, int& lineZeroBased, int& columnZeroBased); + +/// StackFrame::computeLineAndColumn() of a frame with a code block, with the above applied. +ZigStackFramePosition getLineColumnForStackFrame(const JSC::StackFrame& frame); + } // namespace Bun diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index d622679f31aa..f7a21826a70f 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -21,6 +21,7 @@ #include "BunClientData.h" #include "CallSite.h" +#include "ErrorStackFrame.h" #include "ErrorStackTrace.h" #include "headers-handwritten.h" @@ -243,11 +244,11 @@ WTF::String formatStackTrace( // file's map once instead of per frame. WTF::Vector remappedFrames; WTF::Vector sourceURLs; - WTF::Vector originalLineColumns; + WTF::Vector originalPositions; remappedFrames.grow(framesCount); memset(remappedFrames.begin(), 0, sizeof(ZigStackFrame) * framesCount); sourceURLs.grow(framesCount); - originalLineColumns.grow(framesCount); + originalPositions.grow(framesCount); bool anyRemap = false; for (size_t i = 0; i < framesCount; i++) { @@ -260,11 +261,11 @@ WTF::String formatStackTrace( remappedFrame.position.line_zero_based = -1; remappedFrame.position.column_zero_based = -1; remappedFrame.position.byte_position = -1; - originalLineColumns[i] = {}; + originalPositions[i] = remappedFrame.position; if (!frame.hasLineAndColumnInfo()) continue; - originalLineColumns[i] = frame.computeLineAndColumn(); + originalPositions[i] = Bun::getLineColumnForStackFrame(frame); JSC::JSGlobalObject* globalObjectForFrame = lexicalGlobalObject; if (auto* callee = frame.callee()) { @@ -280,8 +281,7 @@ WTF::String formatStackTrace( if (isDefinitelyNotRunninginNodeVMGlobalObject || isDefaultGlobalObjectInAFinalizer) { // https://github.com/oven-sh/bun/issues/3595 if (!sourceURLs[i].isEmpty()) { - remappedFrame.position.line_zero_based = OrdinalNumber::fromOneBasedInt(originalLineColumns[i].line).zeroBasedInt(); - remappedFrame.position.column_zero_based = OrdinalNumber::fromOneBasedInt(originalLineColumns[i].column).zeroBasedInt(); + remappedFrame.position = originalPositions[i]; remappedFrame.source_url = Bun::toStringRef(sourceURLs[i]); anyRemap = true; } @@ -316,8 +316,8 @@ WTF::String formatStackTrace( WTF::String sourceURLForFrame = sourceURLs[i]; if (frame.hasLineAndColumnInfo()) { - originalLine = OrdinalNumber::fromOneBasedInt(originalLineColumns[i].line); - originalColumn = OrdinalNumber::fromOneBasedInt(originalLineColumns[i].column); + originalLine = originalPositions[i].line(); + originalColumn = originalPositions[i].column(); displayLine = originalLine; displayColumn = originalColumn; @@ -575,6 +575,12 @@ static JSValue computeErrorInfoToJSValue(JSC::VM& vm, Vector& stackT return computeErrorInfoToJSValueWithoutSkipping(vm, stackTrace, line, column, sourceURL, errorInstance, bunErrorData); } +// ErrorInstance keeps error.line / error.column unsigned; a node:vm offset can put a position at or below 0, which reads as unset (0) there. +static unsigned toUnsignedOneBased(OrdinalNumber ordinal) +{ + return static_cast(std::max(ordinal.oneBasedInt(), 0)); +} + WTF::String computeErrorInfoWrapperToString(JSC::VM& vm, Vector& stackTrace, unsigned int& line_in, unsigned int& column_in, String& sourceURL, void* bunErrorData) { UNUSED_PARAM(bunErrorData); @@ -607,8 +613,8 @@ WTF::String computeErrorInfoWrapperToString(JSC::VM& vm, Vector& sta result = WTF::emptyString(); } - line_in = line.oneBasedInt(); - column_in = column.oneBasedInt(); + line_in = toUnsignedOneBased(line); + column_in = toUnsignedOneBased(column); return result; } @@ -644,8 +650,8 @@ JSC::JSValue computeErrorInfoWrapperToJSValue(JSC::VM& vm, Vector& s JSValue result = computeErrorInfoToJSValue(vm, stackTrace, line, column, sourceURL, errorInstance, bunErrorData); - line_in = line.oneBasedInt(); - column_in = column.oneBasedInt(); + line_in = toUnsignedOneBased(line); + column_in = toUnsignedOneBased(column); // materializeErrorInfoIfNeeded putDirect()s this unconditionally; an empty JSValue // in property storage crashes the next read. https://github.com/oven-sh/bun/issues/34095 diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 4ed1521cc4bd..bb010ebc897e 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -187,15 +187,11 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c String code = stringifyAnonymousFunction(globalObject, args, throwScope, &startOffset); EXCEPTION_ASSERT(!!throwScope.exception() == code.isNull()); - // 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); + // The body is line 2 of the wrapped program; a negative start is honored by Bun::applyNegativeSourceStart. + int startLine = position.m_line.zeroBasedInt(); + if (startLine != std::numeric_limits::min()) + startLine--; + TextPosition wrappedPosition(OrdinalNumber::fromZeroBasedInt(startLine), position.m_column); SourceCode sourceCode( JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program), @@ -570,23 +566,27 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtr:\n\n\n\n + int reportedLine = static_cast(line_and_column.line); String sourceLineText; unsigned caretColumn = 0; if (JSC::CodeBlock* codeBlock = stack_frame.codeBlock()) { if (JSC::SourceProvider* provider = codeBlock->source().provider()) { - int64_t startLineZeroBased = provider->startPosition().m_line.zeroBasedInt(); - int64_t physicalLine = static_cast(line_and_column.line) - startLineZeroBased; + // line_and_column was counted from the start clamped to 1:1 (see Bun::applyNegativeSourceStart). + TextPosition start = provider->startPosition(); + int startLineZeroBased = start.m_line.zeroBasedInt(); + int64_t physicalLine = static_cast(line_and_column.line) - std::max(startLineZeroBased, 0); + reportedLine = static_cast(physicalLine + startLineZeroBased); sourceLineText = nthSourceLineForArrowHeader(provider->source(), physicalLine); if (!sourceLineText.isNull()) { caretColumn = line_and_column.column; - unsigned startColumnZeroBased = static_cast(provider->startPosition().m_column.zeroBasedInt()); + unsigned startColumnZeroBased = static_cast(std::max(start.m_column.zeroBasedInt(), 0)); if (physicalLine == 1 && caretColumn > startColumnZeroBased) caretColumn -= startColumnZeroBased; } } } - writeArrowHeaderStack(vm, errorInstance, source_url, static_cast(line_and_column.line), sourceLineText, caretColumn, stack); + writeArrowHeaderStack(vm, errorInstance, source_url, reportedLine, sourceLineText, caretColumn, stack); JSC::throwException(globalObject, throwScope, exception.get()); return true; diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..abad429c0549 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -145,13 +145,7 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr return; if (!stackFrame.hasBytecodeIndex()) { - if (stackFrame.hasLineAndColumnInfo()) { - auto lineColumn = stackFrame.computeLineAndColumn(); - position.line_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.line).zeroBasedInt(); - position.column_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.column).zeroBasedInt(); - } - - position.byte_position = -1; + position = Bun::getLineColumnForStackFrame(stackFrame); return; } @@ -333,7 +327,8 @@ class V8StackTraceIterator { auto segment1 = StringView_slice(lineInner, marker1, marker2); auto segment2 = StringView_slice(lineInner, marker2 + 1, marker3); - if (auto int1 = WTF::parseIntegerAllowingTrailingJunk(segment2)) { + // Signed: a node:vm lineOffset can push positions to 0 or below. + if (auto int1 = WTF::parseIntegerAllowingTrailingJunk(segment2)) { frame.sourceURL = segment1; frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int1.value()); } else { @@ -363,8 +358,8 @@ class V8StackTraceIterator { auto segment2 = StringView_slice(lineInner, marker2 + 1, marker3); auto segment3 = StringView_slice(lineInner, marker3 + 1, marker4); - if (auto int1 = WTF::parseIntegerAllowingTrailingJunk(segment2)) { - if (auto int2 = WTF::parseIntegerAllowingTrailingJunk(segment3)) { + if (auto int1 = WTF::parseIntegerAllowingTrailingJunk(segment2)) { + if (auto int2 = WTF::parseIntegerAllowingTrailingJunk(segment3)) { frame.sourceURL = segment1; frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int1.value()); frame.columnNumber = WTF::OrdinalNumber::fromOneBasedInt(int2.value()); @@ -373,7 +368,7 @@ class V8StackTraceIterator { frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int1.value()); } } else { - if (auto int2 = WTF::parseIntegerAllowingTrailingJunk(segment3)) { + if (auto int2 = WTF::parseIntegerAllowingTrailingJunk(segment3)) { frame.sourceURL = StringView_slice(lineInner, marker1, marker3); frame.lineNumber = WTF::OrdinalNumber::fromOneBasedInt(int2.value()); } else { diff --git a/test/js/node/test/parallel/test-vm-basic.js b/test/js/node/test/parallel/test-vm-basic.js index be409b937246..9641fc3b6096 100644 --- a/test/js/node/test/parallel/test-vm-basic.js +++ b/test/js/node/test/parallel/test-vm-basic.js @@ -278,16 +278,15 @@ const vm = require('vm'); 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. + // the throw to a different column (and columnOffset is not applied), and + // the anonymous source is labeled differently. 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 +312,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:16)' }); assert.strictEqual( @@ -337,7 +336,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 c82b29164fcf..6f2a966c7868 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -463,6 +463,200 @@ describe("Script", () => { }); }); +// Position of the first `::` frame in a stack. The header +// node:vm prepends to thrown errors has no column, so it never matches. +function frameAt(stack: string, filename: string) { + const match = stack.match(new RegExp(`${filename.replaceAll(".", "\\.")}:(-?\\d+):(\\d+)`)); + expect(match).not.toBeNull(); + return { line: Number(match![1]), column: Number(match![2]) }; +} + +// lineOffset / columnOffset are signed in Node: a negative offset is how a caller +// compensates for wrapper text it prepended to the source, and V8 reports the +// resulting positions as-is. JSC clamps the position a source starts at to 1:1, +// so the negative part has to be re-applied by Bun, both in the arrow header +// node:vm prepends to a thrown error's stack and in every rendering of the frames. +describe("negative lineOffset and columnOffset", () => { + const fourLines = "1;\n2;\n3;\nthrow new Error('x')"; + + // The header node:vm prepends to a thrown error (`:`, source line, + // caret line, blank line) and the rest of the stack. + function thrown(run: () => unknown) { + let error: any; + try { + run(); + } catch (e) { + error = e; + } + expect(typeof error?.stack).toBe("string"); + const lines: string[] = error.stack.split("\n"); + return { header: lines.slice(0, 4), rest: lines.slice(4).join("\n"), error }; + } + + const runners: Record unknown> = { + "Script#runInThisContext": (code, options) => new Script(code, options).runInThisContext(), + "Script#runInNewContext": (code, options) => new Script(code, options).runInNewContext({}), + "vm.runInThisContext": (code, options) => runInThisContext(code, options), + }; + for (const [name, run] of Object.entries(runners)) { + test(`${name}: header line and frames carry a negative lineOffset`, () => { + const base = thrown(() => run(fourLines, { filename: "n.js" })); + expect(base.header).toEqual(["n.js:4", "throw new Error('x')", expect.stringMatching(/^ *\^$/), ""]); + expect(frameAt(base.rest, "n.js").line).toBe(4); + + const offset = thrown(() => run(fourLines, { filename: "n.js", lineOffset: -2 })); + expect(offset.header).toEqual(["n.js:2", "throw new Error('x')", base.header[2], ""]); + expect(frameAt(offset.rest, "n.js")).toEqual({ ...frameAt(base.rest, "n.js"), line: 2 }); + // JSC's own error.line property follows the top frame. + expect([base.error.line, offset.error.line]).toEqual([4, 2]); + }); + } + + test("the source line shown is the physical one, not the one at line + |offset|", () => { + // Before the offset was taken into account here the header showed the line + // *after* the throw (with the caret under it) whenever it was long enough. + const code = "1;\nthrow new Error('x');\nconsole.log('a line long enough for the caret to fit under it');"; + const { header } = thrown(() => new Script(code, { filename: "w.js", lineOffset: -1 }).runInThisContext()); + expect(header).toEqual(["w.js:1", "throw new Error('x');", expect.stringMatching(/^ *\^$/), ""]); + }); + + test("frames inside functions defined by the script are offset too", () => { + const code = "function f() {\n throw new Error('x');\n}\nf();"; + const { header, rest } = thrown(() => new Script(code, { filename: "nf.js", lineOffset: -1 }).runInThisContext()); + expect(header).toEqual(["nf.js:1", " throw new Error('x');", expect.stringMatching(/^ *\^$/), ""]); + expect(rest).toMatch(/^ at f \(nf\.js:1:\d+\)$/m); + expect(rest).toMatch(/^ at nf\.js:3:\d+$/m); + }); + + test("columnOffset shifts the first line's frame columns either way; the caret stays on the text", () => { + const code = " throw new Error('x')"; + const run = (options: object) => + thrown(() => new Script(code, { filename: "c.js", ...options }).runInThisContext()); + const base = run({}); + expect(base.header).toEqual(["c.js:1", code, expect.stringMatching(/^ *\^$/), ""]); + const baseFrame = frameAt(base.rest, "c.js"); + + for (const columnOffset of [3, -3]) { + const offset = run({ columnOffset }); + // Same caret line: the caret marks a column of the text, which did not move. + expect(offset.header).toEqual(base.header); + expect(frameAt(offset.rest, "c.js")).toEqual({ line: 1, column: baseFrame.column + columnOffset }); + } + }); + + test("header lines at or below zero are reported like Node does", () => { + const far = thrown(() => new Script(fourLines, { filename: "nn.js", lineOffset: -10 }).runInThisContext()); + expect(far.header).toEqual(["nn.js:-6", "throw new Error('x')", expect.stringMatching(/^ *\^$/), ""]); + + const zero = thrown(() => + new Script(" throw new Error('x')", { filename: "z.js", lineOffset: -1, columnOffset: -1 }).runInThisContext(), + ); + expect(zero.header).toEqual(["z.js:0", " throw new Error('x')", expect.stringMatching(/^ *\^$/), ""]); + + // error.line is unsigned in JSC; such positions read as unset instead of wrapping around. + expect([far.error.line, zero.error.line]).toEqual([0, 0]); + }); + + const makesError = "'line 1';\nfunction f() {\n return new Error('x');\n}\nf();"; + + test("Error.prepareStackTrace call sites see the offsets", () => { + // Positions of the call sites in `filename` as [line, column] pairs. + const callSites = (code: string, options: { filename: string; lineOffset?: number; columnOffset?: number }) => { + const previous = Error.prepareStackTrace; + Error.prepareStackTrace = (_, sites) => + sites.filter(s => s.getFileName() === options.filename).map(s => [s.getLineNumber(), s.getColumnNumber()]); + try { + return new Script(code, options).runInThisContext().stack as [number, number][]; + } finally { + Error.prepareStackTrace = previous; + } + }; + + const base = callSites(makesError, { filename: "cs.js" }); + expect(base.map(([line]) => line)).toEqual([3, 5]); + const lines = callSites(makesError, { filename: "cs.js", lineOffset: -1 }); + expect(lines).toEqual([ + [2, base[0][1]], + [4, base[1][1]], + ]); + + const code = " new Error('x')"; + const [[, baseColumn]] = callSites(code, { filename: "cc.js" }); + expect(callSites(code, { filename: "cc.js", columnOffset: -3 })).toEqual([[1, baseColumn - 3]]); + + // Call sites of a `new` expression are reported at the `new` keyword. JSC's + // own position for this one is on line 2, so the column offset has to be + // applied after the position has been moved back onto the first line. + const wrapped = " new\nError('x')"; + const [[wrappedLine, wrappedColumn]] = callSites(wrapped, { filename: "nl.js" }); + expect(wrappedLine).toBe(1); + expect(callSites(wrapped, { filename: "nl.js", columnOffset: -3 })).toEqual([[1, wrappedColumn - 3]]); + }); + + test("Bun.inspect() prints the offset line numbers", () => { + const error = new Script(makesError, { filename: "insp.js", lineOffset: -1 }).runInThisContext(); + const inspected = Bun.inspect(error); + expect(inspected).toMatch(/^2 \| +return new Error\('x'\);$/m); + expect(inspected).toMatch(/at f \(insp\.js:2:\d+\)$/m); + expect(inspected).toMatch(/at insp\.js:4:\d+$/m); + }); + + test("compileFunction reports body line N as N + lineOffset for every sign of the offset", () => { + const body = "1;\nthrow new Error('x')"; + const lineFor = (lineOffset: number) => { + let stack: string | undefined; + try { + compileFunction(body, [], { filename: "cf.js", lineOffset })(); + } catch (e: any) { + stack = e.stack; + } + return frameAt(stack!, "cf.js").line; + }; + expect([-1, 0, 3].map(lineFor)).toEqual([1, 2, 5]); + }); + + test.concurrent("uncaught errors are printed with the offset positions", async () => { + const run = async (code: string, options: object) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `require("node:vm").runInThisContext(${JSON.stringify(code)}, ${JSON.stringify(options)})`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + return { stderr, exitCode }; + }; + + // displayErrors: false leaves err.stack alone, so the error printer shows its + // own source excerpt and frames instead of the header checked above. + const code = "1;\n2;\nfunction f() {\n throw new Error('x');\n}\nf();"; + const [excerpt, belowZero, belowZeroDecorated] = await Promise.all([ + run(code, { filename: "n.js", lineOffset: -2, displayErrors: false }), + run(code, { filename: "nn.js", lineOffset: -10, displayErrors: false }), + run(code, { filename: "nn.js", lineOffset: -10 }), + ]); + + expect(excerpt.stderr).toMatch(/^2 \| +throw new Error\('x'\);$/m); + expect(excerpt.stderr).toMatch(/at f \(n\.js:2:\d+\)$/m); + expect(excerpt.stderr).toMatch(/at n\.js:4:\d+$/m); + expect(excerpt.exitCode).toBe(1); + + // The printer cannot show a line at or below 0, so such frames are printed + // without a position, whether it reads the frames directly or re-parses + // the `at f (nn.js:-6:9)` lines of the stack node:vm already decorated. + expect(belowZero.stderr).toMatch(/^\s+at f \(nn\.js\)$/m); + expect(belowZero.stderr).toMatch(/^\s+at nn\.js$/m); + expect(belowZero.exitCode).toBe(1); + expect(belowZeroDecorated.stderr).toMatch(/^\s+at f \(nn\.js\)$/m); + expect(belowZeroDecorated.exitCode).toBe(1); + }); +}); + type TestRunInContextArg = | { fn: typeof runInContext; isIsolated: true; isNew?: boolean } | { fn: typeof runInThisContext; isIsolated?: false; isNew?: boolean }; @@ -671,11 +865,38 @@ function testRunInContext({ fn, isIsolated, isNew }: TestRunInContextArg) { test.todo("can specify filename", () => { // }); - test.todo("can specify lineOffset", () => { - // + // Runs `code`, which evaluates to a stack string, through whichever entry + // point this suite exercises and returns the position of its frame in `file`. + const positionOf = (code: string, offsets: { lineOffset?: number; columnOffset?: number } = {}) => { + const options = { filename: "offsets.js", ...offsets }; + const stack: string = isIsolated + ? (fn as typeof runInContext)(code, createContext({}), options) + : (fn as typeof runInThisContext)(code, options); + return frameAt(stack, options.filename); + }; + + test("can specify lineOffset", () => { + // `new Error()` sits on physical line 4; lineOffset is added to it in + // either direction (a negative offset is how callers compensate for + // wrapper lines they prepended to the source). + const code = "\n\n\n new Error().stack;"; + expect(positionOf(code).line).toBe(4); + expect(positionOf(code, { lineOffset: 10 }).line).toBe(14); + expect(positionOf(code, { lineOffset: -2 }).line).toBe(2); }); - test.todo("can specify columnOffset", () => { - // + test("can specify columnOffset", () => { + // columnOffset applies to the first physical line of the source only, in + // either direction. The column JSC attributes to the expression is taken + // from an un-offset run so only the offset arithmetic is asserted here. + const code = " new Error().stack;"; + const base = positionOf(code); + expect(base.line).toBe(1); + expect(positionOf(code, { columnOffset: 10 })).toEqual({ line: 1, column: base.column + 10 }); + expect(positionOf(code, { columnOffset: -2 })).toEqual({ line: 1, column: base.column - 2 }); + // Still the first physical line when lineOffset moves its number. + expect(positionOf(code, { lineOffset: 5, columnOffset: -2 })).toEqual({ line: 6, column: base.column - 2 }); + // Not the first physical line: the column is untouched. + expect(positionOf("\n" + code, { lineOffset: -1, columnOffset: -2 })).toEqual({ line: 1, column: base.column }); }); test.todo("can specify timeout", () => { //