From 95dbd436e4fb24e298e5c00523d8eeadceee6043 Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Thu, 20 Aug 2026 13:31:32 +0530 Subject: [PATCH 1/2] fix: locate inline snapshots in tail-call position When toMatchInlineSnapshot() is the last expression of a test function body, JavaScriptCore applies tail-call optimization and elides the caller frame, so get_caller_src_loc() returns an empty location and the snapshot writer reports 'called from file: '. expect() itself is never in tail position (its result feeds the matcher member access), so capture its caller src loc when the Expect is created and fall back to it when the matcher walk finds nothing. The matcher call site is then relocated by reading the test file and finding the fn_name( call at/after the expect() position. --- src/jsc/lib.rs | 2 +- src/runtime/test_runner/expect.rs | 162 +++++++++++++++++++++++++++++- test/cli/test/bun-test.test.ts | 42 +++++++- 3 files changed, 201 insertions(+), 5 deletions(-) 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 ?? ""]; From b861c74fe29f91d0b11e4c45fa39449bfbf7af1e Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Thu, 20 Aug 2026 18:03:15 +0530 Subject: [PATCH 2/2] bundler: report unknown node: builtins as No such built-in module --- src/bundler/bundle_v2.rs | 24 +++++++++++++++++++++++ src/bundler/linker.rs | 12 ++++++++++++ test/js/bun/resolve/resolve-error.test.ts | 15 ++++++++++++++ 3 files changed, 51 insertions(+) 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/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";