diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 548d08b4e916..3c35a95e64db 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2367,6 +2367,18 @@ pub mod bv2_impl { path_to_use, import_record.kind, ); + } else if import_record.specifier.starts_with(b"node:") { + add_error( + log, + source, + import_record.range, + format_args!( + "No such built-in module: {}", + bstr::BStr::new(path_to_use) + ), + path_to_use, + import_record.kind, + ); } else { add_error( log, @@ -6271,6 +6283,18 @@ pub mod bv2_impl { import_record.path.text, import_record.kind, ); + } else if import_record.path.text.starts_with(b"node:") { + add_error( + log, + Some(source), + import_record.range, + format_args!( + "No such built-in module: {}", + bstr::BStr::new(&import_record.path.text) + ), + import_record.path.text, + import_record.kind, + ); } else { add_error( log, diff --git a/src/bundler/linker.rs b/src/bundler/linker.rs index 75d347b85062..582f2ac770b6 100644 --- a/src/bundler/linker.rs +++ b/src/bundler/linker.rs @@ -572,6 +572,18 @@ impl Linker { import_record.kind, bun_ast::Error::ModuleNotFound, ); + } else if import_record.path.text.starts_with(b"node:") { + log.add_resolve_error( + Some(source), + import_record.range, + format_args!( + "No such built-in module: {}", + bstr::BStr::new(import_record.path.text) + ), + import_record.path.text, + import_record.kind, + bun_ast::Error::ModuleNotFound, + ); } else { log.add_resolve_error( Some(source), diff --git a/src/js/node/util.ts b/src/js/node/util.ts index df41487dcff3..4e449ae381dd 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -24,6 +24,113 @@ const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames; const { uncurryThis, SafeMap } = require("internal/primordials"); const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec); +const ArrayPrototypePush = Array.prototype.push; +const ArrayPrototypeReverse = Array.prototype.reverse; + +function areLinesEqual(actual, expected) { + return actual === expected; +} + +// Myers diff (Node's internal/assert/myers_diff). Returns an array of +// [operation, value] pairs: -1 delete, 0 no-op, 1 insert. +function myersDiff(actual, expected) { + const actualLength = actual.length; + const expectedLength = expected.length; + const max = actualLength + expectedLength; + const v = new Int32Array(2 * max + 1); + const trace = []; + + for (let diffLevel = 0; diffLevel <= max; diffLevel++) { + ArrayPrototypePush.call(trace, new Int32Array(v)); + + for (let diagonalIndex = -diffLevel; diagonalIndex <= diffLevel; diagonalIndex += 2) { + const offset = diagonalIndex + max; + const previousOffset = v[offset - 1]; + const nextOffset = v[offset + 1]; + let x = + diagonalIndex === -diffLevel || (diagonalIndex !== diffLevel && previousOffset < nextOffset) + ? nextOffset + : previousOffset + 1; + let y = x - diagonalIndex; + + while (x < actualLength && y < expectedLength && areLinesEqual(actual[x], expected[y])) { + x++; + y++; + } + + v[offset] = x; + + if (x >= actualLength && y >= expectedLength) { + return backtrack(trace, actual, expected); + } + } + } +} + +function backtrack(trace, actual, expected) { + const actualLength = actual.length; + const expectedLength = expected.length; + const max = actualLength + expectedLength; + + let x = actualLength; + let y = expectedLength; + const result = []; + + for (let diffLevel = trace.length - 1; diffLevel >= 0; diffLevel--) { + const v = trace[diffLevel]; + const diagonalIndex = x - y; + const offset = diagonalIndex + max; + + let prevDiagonalIndex; + if ( + diagonalIndex === -diffLevel || + (diagonalIndex !== diffLevel && v[offset - 1] < v[offset + 1]) + ) { + prevDiagonalIndex = diagonalIndex + 1; + } else { + prevDiagonalIndex = diagonalIndex - 1; + } + + const prevX = v[prevDiagonalIndex + max]; + const prevY = prevX - prevDiagonalIndex; + + while (x > prevX && y > prevY) { + ArrayPrototypePush.call(result, [0, actual[x - 1]]); + x--; + y--; + } + + if (diffLevel > 0) { + if (x > prevX) { + ArrayPrototypePush.call(result, [1, actual[--x]]); + } else { + ArrayPrototypePush.call(result, [-1, expected[--y]]); + } + } + } + + return result; +} + +function validateDiffInput(value, name) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + validateString(value[i], `${name}[${i}]`); + } + return; + } + validateString(value, name); +} + +function diff(actual, expected) { + if (actual === expected) { + return []; + } + validateDiffInput(actual, "actual"); + validateDiffInput(expected, "expected"); + return ArrayPrototypeReverse.call(myersDiff(actual, expected)); +} + var cjs_exports; function isBuffer(value) { @@ -698,6 +805,7 @@ cjs_exports = { debug: debuglog, debuglog, deprecate, + diff, format, styleText, formatWithOptions, diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 3e5239d28319..870bc8559bb5 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -4248,6 +4248,25 @@ JSC_DEFINE_HOST_FUNCTION(Process_stubEmptyFunction, (JSGlobalObject * globalObje return JSValue::encode(jsUndefined()); } +JSC_DEFINE_CUSTOM_GETTER(processSourceMapsEnabled, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + Zig::GlobalObject* globalObj = defaultGlobalObject(globalObject); + return JSValue::encode(jsBoolean(globalObj->processObject()->m_sourceMapsEnabled)); +} + +JSC_DEFINE_CUSTOM_SETTER(setProcessSourceMapsEnabled, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, JSC::PropertyName propertyName)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue value = JSValue::decode(encodedValue); + if (!value.isBoolean()) { + return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "enabled"_s, "boolean"_s, value); + } + Zig::GlobalObject* globalObj = defaultGlobalObject(globalObject); + globalObj->processObject()->m_sourceMapsEnabled = value.toBoolean(globalObject); + return true; +} + JSC_DEFINE_HOST_FUNCTION(Process_setSourceMapsEnabled, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -4901,6 +4920,7 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu send constructProcessSend PropertyCallback setSourceMapsEnabled Process_setSourceMapsEnabled Function 1 setUncaughtExceptionCaptureCallback Process_setUncaughtExceptionCaptureCallback Function 1 + sourceMapsEnabled processSourceMapsEnabled CustomAccessor stderr constructStderr PropertyCallback stdin constructStdin PropertyCallback stdout constructStdout PropertyCallback diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index f14e2e5be5fa..9d86d0274f37 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -1110,7 +1110,7 @@ pub use self::js_object::{ExternColumnIdentifier, ExternColumnIdentifierValue, J // ────────────────────────────────────────────────────────────────────────── #[path = "CallFrame.rs"] pub mod call_frame; -pub use self::call_frame::{ArgumentsSlice, CallFrame}; +pub use self::call_frame::{ArgumentsSlice, CallFrame, CallerSrcLoc}; /// Lives here (not in `bun_sys_jsc`) because the orphan /// rule requires either the trait or the type to be local; `FromJsEnum` is. diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index 818fc794f830..ec310c7615cc 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -4,11 +4,11 @@ use core::fmt; use bun_core::Output; use bun_jsc::{ - CallFrame, JSGlobalObject, JSValue, JsError, JsResult, + CallFrame, CallerSrcLoc, JSGlobalObject, JSValue, JsError, JsResult, ConsoleObject, JSFunction, JSPropertyIterator, JSString, }; use bun_jsc::{JsClass as _, StringJsc as _}; -use bun_core::ZigString; +use bun_core::{ZStr, ZigString}; use bun_jsc::js_promise; use bun_jsc::virtual_machine::VirtualMachine; use bun_core::strings; @@ -41,6 +41,10 @@ pub struct Expect { pub(crate) flags: Cell, pub(crate) parent: Option, pub(crate) custom_label: bun_core::String, + /// Caller source location captured when `expect()` was called. Not in + /// tail position (its result feeds the matcher member access), so it is + /// intact even when JSC elides the caller frame for a tail-called matcher. + pub(crate) caller_src_loc: CallerSrcLoc, } @@ -684,6 +688,7 @@ impl Expect { #[allow(clippy::boxed_local)] pub fn finalize(mut self: Box) { self.custom_label.deref(); + self.caller_src_loc.str.deref(); // RefDataPtr = RefPtr has NO `Drop` impl (src/ptr/ref_count.rs) // so the Box drop below would leak the +1 — release explicitly. if let Some(parent) = self.parent.take() { @@ -719,9 +724,16 @@ impl Expect { // error path between ref creation and the wrapper taking ownership; from // then on `Expect::finalize` derefs `parent` (RefDataPtr has no Drop). + // Capture the caller src loc now: `expect()` is never in tail position + // (its result feeds the matcher member access), so the caller frame is + // intact here even when the matcher call itself is tail-called and JSC + // elides the caller frame (see `inline_snapshot` fallback). + let caller_src_loc = callframe.get_caller_src_loc(global_this); + let expect = Expect { flags: Cell::new(Flags::default()), custom_label, + caller_src_loc, parent: active_execution_entry_ref, }; // `JsClass::to_js` boxes `self` and hands the pointer to `${T}__create`. @@ -1120,7 +1132,25 @@ impl Expect { let buntest = buntest_strong.get(); // 1. find the src loc of the snapshot - let srcloc = call_frame.get_caller_src_loc(global_this); + let mut srcloc = call_frame.get_caller_src_loc(global_this); + // When the matcher is the last expression of a function body, + // JavaScriptCore applies tail-call optimization and elides the + // caller frame, so the walk above finds no location. `expect()` + // itself is never in tail position (its result feeds the matcher + // member access), so fall back to the location captured when the + // `Expect` was created — it points at the `expect(` call, and the + // matcher call site is relocated from the test file below. + let mut relocated_from_expect = false; + if srcloc.str.is_empty() { + // release the +1 captured by the failed walk above + bun_core::OwnedString::new(srcloc.str); + srcloc = CallerSrcLoc { + str: this.caller_src_loc.str.clone_ref(), + line: this.caller_src_loc.line, + column: this.caller_src_loc.column, + }; + relocated_from_expect = true; + } // bun_core::String is Copy // with no Drop, so wrap in the RAII guard to release the +1 on // every exit path (including the early returns below). @@ -1143,6 +1173,20 @@ impl Expect { } // 2. save to write later + if relocated_from_expect { + // The fallback location points at the `expect(` call, but the + // writer needs the matcher call site — read the test file and + // find the `fn_name(` call at/after the `expect()` position. + if let Some((line, column)) = Self::locate_matcher_call_in_test_file( + fget_source_path_text, + u64::from(srcloc.line), + u64::from(srcloc.column), + fn_name.as_bytes(), + ) { + srcloc.line = u32::try_from(line).unwrap_or(srcloc.line); + srcloc.column = u32::try_from(column).unwrap_or(srcloc.column); + } + } runner.snapshots.add_inline_snapshot_to_write(file_id, super::snapshot::InlineSnapshotToWrite { line: core::ffi::c_ulong::from(srcloc.line), col: core::ffi::c_ulong::from(srcloc.column), @@ -1196,6 +1240,73 @@ impl Expect { Ok(()) } + /// Read the test file and find the first `fn_name(` call site at/after the + /// given `expect()` position (1-based line/column). Used to relocate the + /// [`Expect::caller_src_loc`] fallback — which points at `expect(` — to the + /// actual matcher call site when JSC elided the caller frame (tail call). + fn locate_matcher_call_in_test_file( + test_file_path: &ZigString, + expect_line: u64, + expect_col: u64, + fn_name: &[u8], + ) -> Option<(u64, u64)> { + let mut path_buf = test_file_path.to_vec(); + path_buf.push(0); + // SAFETY: NUL appended above + let path_z = ZStr::from_slice_with_nul(&path_buf); + let fd = match bun_sys::open(path_z, bun_sys::O::RDONLY, 0) { + bun_sys::Result::Ok(r) => r, + bun_sys::Result::Err(_) => return None, + }; + let file_text: Vec = match bun_sys::File::from_fd(fd).read_to_end() { + bun_sys::Result::Ok(t) => t, + bun_sys::Result::Err(_) => return None, + }; + Self::locate_matcher_call(&file_text, expect_line, expect_col, fn_name) + } + + /// Search `file_text` for the first `fn_name(` call at/after the given + /// 1-based line/column and return its line/column. + fn locate_matcher_call( + file_text: &[u8], + expect_line: u64, + expect_col: u64, + fn_name: &[u8], + ) -> Option<(u64, u64)> { + let start = bun_ast::Source::line_col_to_byte_offset(file_text, 1, 1, expect_line, expect_col)?; + let mut i = start; + while i < file_text.len() { + if file_text[i..].starts_with(fn_name) { + let after = i + fn_name.len(); + let mut j = after; + while j < file_text.len() && matches!(file_text[j], b' ' | b'\t') { + j += 1; + } + if j < file_text.len() + && file_text[j] == b'(' + // the name must be a real property access / call token, not + // a substring of an identifier or of a string/comment + && (i == 0 + || !matches!( + file_text[i - 1], + b'a'..=b'z' + | b'A'..=b'Z' + | b'0'..=b'9' + | b'_' + | b'$' + | b'"' + | b'\'' + | b'`' + )) + { + return line_col_of_byte(file_text, i); + } + } + i += 1; + } + None + } + pub(crate) fn snapshot( &self, global_this: &JSGlobalObject, @@ -3268,6 +3379,51 @@ unsafe extern "C" { // Exports: handled by #[unsafe(no_mangle)] on: // ExpectMatcherUtils_createSigleton, Expect_readFlagsAndProcessPromise, ExpectCustomAsymmetricMatcher__execute +/// 1-based line/column of the codepoint starting at `byte` in `text`, using the +/// same newline/column semantics as `Source::line_col_to_byte_offset` (so the +/// round trip `byte → line/col → byte` is exact). +fn line_col_of_byte(text: &[u8], byte: usize) -> Option<(u64, u64)> { + use bun_core::strings::{CodepointIterator, Cursor}; + let iter_ = CodepointIterator::init(text); + let mut iter = Cursor::default(); + let _ = iter_.next(&mut iter); + let mut line: u64 = 1; + let mut column: u64 = 1; + loop { + let c = iter.c; + let start = iter.i as usize; + if start == byte { + return Some((line, column)); + } + if start > byte { + return None; + } + if !iter_.next(&mut iter) { + break; + } + match c { + 0x0A => { + column = 1; + line += 1; + } + 0x0D => { + column = 1; + line += 1; + if iter.c == ('\n' as i32) { + let _ = iter_.next(&mut iter); + } + } + 0x2028 | 0x2029 => { + line += 1; + column = 1; + } + _ => { + column += if c > 0xFFFF { 2 } else { 1 }; + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 566279d3e877..56031bae34e2 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, it, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; describe("bun test", () => { @@ -1750,6 +1750,46 @@ describe("bun test", () => { }); }); +describe("inline snapshots", () => { + test("matcher in tail position is located (JSC elides the caller frame)", () => { + const cwd = createTest([ + { + filename: "tail-inline.test.ts", + contents: ` + import { test, expect } from "bun:test"; + test("expression body", () => expect(1).toMatchInlineSnapshot()); + test("explicit return", () => { + return expect("a").toMatchInlineSnapshot(); + }); + test("multi-line expect", () => + expect({ + a: 1, + }).toMatchInlineSnapshot(), + ); + `, + }, + ]); + try { + const { stderr, exitCode } = spawnSync({ + cwd, + cmd: [bunExe(), "test", "--update-snapshots", "tail-inline.test.ts"], + env: { ...bunEnv, CI: "false", AGENT: "0" }, + stderr: "pipe", + stdout: "ignore", + }); + expect(exitCode).toBe(0); + expect(stderr.toString()).not.toContain("called from file"); + expect(stderr.toString()).not.toContain("Matcher error"); + const updated = readFileSync(join(cwd, "tail-inline.test.ts"), "utf8"); + expect(updated).toContain('expect(1).toMatchInlineSnapshot(`1`)'); + expect(updated).toContain('expect("a").toMatchInlineSnapshot(`"a"`)'); + expect(updated).toContain(".toMatchInlineSnapshot(`"); + } finally { + rmSync(cwd, { recursive: true }); + } + }); +}); + function createTest(input?: string | (string | { filename: string; contents: string })[], filename?: string): string { const cwd = tmpdirSync(); const inputs = Array.isArray(input) ? input : [input ?? ""]; diff --git a/test/js/bun/resolve/resolve-error.test.ts b/test/js/bun/resolve/resolve-error.test.ts index c608c2800a83..fe7cd6689573 100644 --- a/test/js/bun/resolve/resolve-error.test.ts +++ b/test/js/bun/resolve/resolve-error.test.ts @@ -91,6 +91,21 @@ describe("ResolveMessage", () => { expect(err.message).toContain(referrer); }); + it("reports an unknown node: builtin as No such built-in module (bundler)", async () => { + const spec = "node:definitely-not-a-real-module"; + const lineText = `import "${spec}";`; + const fileName = "entry-unknown-node-builtin.js"; + using dir = tempDir("resolve-unknown-node-builtin", { + [fileName]: lineText + "\n", + }); + const result = await Bun.build({ entrypoints: [path.join(String(dir), fileName)], throw: false }); + expect(result.success).toBe(false); + const log: any = result.logs.find(l => l.name === "ResolveMessage"); + expect(log).toBeDefined(); + expect(log.specifier).toBe(spec); + expect(log.message).toBe(`No such built-in module: ${spec}`); + }); + it("preserves non-ASCII in position.lineText and position.file", async () => { const lineText = `const caf\u00e9 = 1; import "./na\u00efve-missing.js"; // \u{1F389}`; const fileName = "entry-caf\u00e9-\u{1F389}.js"; diff --git a/test/js/node/process/process-sourcemaps-enabled.test.ts b/test/js/node/process/process-sourcemaps-enabled.test.ts new file mode 100644 index 000000000000..194f460a3211 --- /dev/null +++ b/test/js/node/process/process-sourcemaps-enabled.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; + +describe("process.sourceMapsEnabled", () => { + test("is a boolean accessor", () => { + expect(typeof process.sourceMapsEnabled).toBe("boolean"); + const descriptor = Object.getOwnPropertyDescriptor(process, "sourceMapsEnabled"); + expect(typeof descriptor?.get).toBe("function"); + }); + + test("reflects setSourceMapsEnabled()", () => { + const original = process.sourceMapsEnabled; + process.setSourceMapsEnabled(true); + expect(process.sourceMapsEnabled).toBe(true); + process.setSourceMapsEnabled(original); + expect(process.sourceMapsEnabled).toBe(original); + }); +}); \ No newline at end of file diff --git a/test/js/node/util/util-diff.test.ts b/test/js/node/util/util-diff.test.ts new file mode 100644 index 000000000000..eaacb610fb6e --- /dev/null +++ b/test/js/node/util/util-diff.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { diff } from "node:util"; + +describe("util.diff", () => { + test("identical inputs", () => { + expect(diff("abc", "abc")).toEqual([]); + }); + + test("string replacement", () => { + expect(diff("abc", "abd")).toEqual([ + [0, "a"], + [0, "b"], + [1, "c"], + [-1, "d"], + ]); + }); + + test("string insertion/deletion", () => { + expect(diff("", "a")).toEqual([[-1, "a"]]); + expect(diff("a", "")).toEqual([[1, "a"]]); + }); + + test("arrays of strings", () => { + expect(diff(["a", "b", "c"], ["a", "b", "d"])).toEqual([ + [0, "a"], + [0, "b"], + [1, "c"], + [-1, "d"], + ]); + }); + + test("throws on non-string values", () => { + expect(() => diff(1, 2)).toThrow(); + expect(() => diff(["a", 1], ["a"])).toThrow(); + }); +}); \ No newline at end of file