diff --git a/docs/runtime/plugins.mdx b/docs/runtime/plugins.mdx index cac443d0a849..bdb38c22f333 100644 --- a/docs/runtime/plugins.mdx +++ b/docs/runtime/plugins.mdx @@ -244,6 +244,38 @@ Bun.build({ This plugin transforms all imports of the form `import env from "env"` into a JavaScript module that exports the current environment variables. +#### Source maps + +A plugin that you register with `Bun.plugin()` can end `contents` with an inline source map of the file it rewrote: a `//# sourceMappingURL=` comment on a line of its own, with the map as a base64 `data:application/json` URL. Bun then reports stack traces and `bun test --coverage` lines at their positions in the file instead of their positions in `contents`. + +```ts plugin.ts icon="/icons/typescript.svg" +import MagicString from "magic-string"; +import { basename } from "node:path"; + +Bun.plugin({ + name: "banner", + setup(build) { + build.onLoad({ filter: /\.ts$/ }, async ({ path }) => { + const code = new MagicString(await Bun.file(path).text()); + code.prepend("/* generated */\n"); + const map = code.generateMap({ source: basename(path), hires: true }); + return { + contents: `${code}\n//# sourceMappingURL=${map.toUrl()}`, + loader: "ts", + }; + }); + }, +}); +``` + +Load `plugin.ts` with [`preload`](/runtime/bunfig#preload), so that it runs before the files it rewrites. + +The map's `sources` must have exactly one entry, and it must resolve to the file. Bun resolves a relative entry from the file's directory, and does not read `sourceRoot`. Bun ignores any other map. + +Code that the map does not cover has no position in the file. Coverage leaves it out, and a stack frame in it keeps its position in `contents`. + +`Bun.build()` does not read the comment. With `--inspect`, the debugger shows `contents`, so all positions stay in `contents`. + #### `.defer()` The `onLoad` callback receives a `defer` function, which returns a `Promise` that resolves once Bun has loaded all _other_ modules. Await it when a module's contents depend on other modules. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2ded9f1ad305..39f2171505c3 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6387,6 +6387,15 @@ declare module "bun" { interface OnLoadResultSourceCode { /** * The source code of the module + * + * In a runtime plugin, `contents` can end with a + * `//# sourceMappingURL=data:application/json;base64,...` comment. If the + * only entry in that map's `sources` resolves to the file being loaded, + * stack traces and `bun test --coverage` report positions in the file + * instead of positions in `contents`. Code that the map does not cover + * keeps its position in `contents` and is left out of coverage. + * + * The comment has no effect in `Bun.build()` or with `--inspect`. */ contents: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer; /** diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 3b8925c4b08d..b5d43a264305 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1267,6 +1267,8 @@ pub fn write_json_string( pub struct SourceMapHandler<'a> { pub(crate) ctx: NonNull<()>, pub(crate) callback: fn(*mut (), SourceMap::Chunk, &bun_ast::Source) -> crate::Result<()>, + /// The map the source came with. + pub input_source_map: Option<&'a SourceMap::ParsedSourceMap>, _marker: core::marker::PhantomData<&'a mut ()>, } @@ -1306,6 +1308,7 @@ impl<'a> SourceMapHandler<'a> { // Type-erased to `*mut ()` and cast back to `*mut T` inside the thunk before dereference. ctx: NonNull::from(ctx).cast::<()>(), callback: thunk::, + input_source_map: None, _marker: core::marker::PhantomData, } } @@ -7673,6 +7676,10 @@ pub(crate) fn get_source_map_builder<'a, const IS_BUN_PLATFORM: bool>( }, None => LineOffsetTables::None, }, + input_source_map: opts + .source_map_handler + .as_ref() + .and_then(|handler| handler.input_source_map), ..Default::default() }; // Pre-size the VLQ mappings buffer. With `--minify` we emit roughly one diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 4fbe9c6944be..83d57453d96e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1722,6 +1722,7 @@ impl VirtualMachine { SourceMapHandlerGetter { vm: self, printer, + input_source_map: None, _marker: core::marker::PhantomData, } } @@ -3824,6 +3825,8 @@ pub fn process_fetch_log( pub struct SourceMapHandlerGetter<'a> { vm: *mut VirtualMachine, printer: *mut bun_js_printer::BufferPrinter, + /// The map the source came with. + pub input_source_map: Option<&'a bun_sourcemap::ParsedSourceMap>, _marker: core::marker::PhantomData<&'a mut ()>, } @@ -3843,6 +3846,7 @@ impl<'a> SourceMapHandlerGetter<'a> { Self { vm, printer, + input_source_map: None, _marker: core::marker::PhantomData, } } @@ -3901,11 +3905,15 @@ impl<'a> SourceMapHandlerGetter<'a> { Some(d) if d.mode != crate::debugger::Mode::Connect ); if !wants_inline_source_map { + let input_source_map = self.input_source_map; // `source_mappings` is a value field on the VM, exclusively // borrowed for the returned handler's lifetime (bounded by // `&mut self`). - return bun_js_printer::SourceMapHandler::for_(self.vm_source_mappings_mut()); + let mut handler = bun_js_printer::SourceMapHandler::for_(self.vm_source_mappings_mut()); + handler.input_source_map = input_source_map; + return handler; } + // The inspector shows the source, so the mappings stay positions in it. bun_js_printer::SourceMapHandler::for_(self) } } diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 4a9da411e5f9..f4555f85a7a9 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -2491,6 +2491,7 @@ fn transpile_source_code_of_any_provenance( // ── Node-fallback virtual source ──────────────────────────────── let fallback_source: bun_ast::Source; let mut virtual_source = args.virtual_source; + let input_source_map = virtual_source.and_then(bun_sourcemap::parse_input_source_map); if is_node_override { if let Some(code) = node_fallbacks::contents_from_path(specifier) { { @@ -2633,7 +2634,9 @@ fn transpile_source_code_of_any_provenance( keep_json_and_toml_as_one_statement: true, allow_bytecode_cache: true, set_breakpoint_on_first_line, + // The cache's key is the text. With an input source map the path matters too. runtime_transpiler_cache: if !disable_transpilying + && input_source_map.is_none() && !::disabled() { Some(&mut cache) @@ -3102,6 +3105,7 @@ fn transpile_source_code_of_any_provenance( // is bounded by the block. let mut mapper = unsafe { (*jsc_vm).source_map_handler((*extra).source_code_printer) }; + mapper.input_source_map = input_source_map.as_deref(); // SAFETY: per fn contract — `jsc_vm` / `extra.source_code_printer` // are live; the printer borrow is scoped to this call. let print_result = unsafe { diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 8ce05d725e7c..7af146406280 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -4,8 +4,9 @@ use bun_paths::fs::FileSystem; use bun_ptr::RawSlice; use crate::{ - InternalSourceMap, LineOffsetTable, SourceMapState, append_mapping_to_buffer, - internal_source_map, line_offset_table, line_offset_table::LineOffsetTableColumns as _, + InternalSourceMap, LineOffsetTable, Ordinal, ParsedSourceMap, SourceMapState, + append_mapping_to_buffer, internal_source_map, line_offset_table, + line_offset_table::LineOffsetTableColumns as _, }; #[derive(Clone)] @@ -350,6 +351,9 @@ impl LineOffsetTables<'_> { } } +/// The `source_index` of a mapping that [`NewBuilder::input_source_map`] does not cover. +pub const NOT_IN_INPUT_SOURCE_MAP: i32 = 1; + pub struct NewBuilder<'a, T: SourceMapFormatCtx> { pub source_map: SourceMapFormat, pub line_offset_tables: LineOffsetTables<'a>, @@ -382,6 +386,9 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { /// When generating sourcemappings for bun, we store a count of how many mappings there were pub prepend_count: bool, + + /// The map the source came with. Each mapping goes through it, or gets [`NOT_IN_INPUT_SOURCE_MAP`]. + pub input_source_map: Option<&'a ParsedSourceMap>, } impl Default for NewBuilder<'_, T> { @@ -401,6 +408,7 @@ impl Default for NewBuilder<'_, T> { line_starts_with_mapping: false, cover_lines_without_mappings: false, prepend_count: false, + input_source_map: None, } } } @@ -603,7 +611,23 @@ impl NewBuilder<'_, VLQSourceMap> { } #[inline(always)] - pub(crate) fn append_mapping(&mut self, current_state: SourceMapState) { + pub(crate) fn append_mapping(&mut self, mut current_state: SourceMapState) { + if let Some(input) = self.input_source_map { + if let Some(mapping) = input.find_mapping( + Ordinal::from_zero_based(current_state.original_line), + Ordinal::from_zero_based(current_state.original_column), + ) { + current_state.source_index = 0; + current_state.original_line = mapping.original.lines.zero_based(); + // The distance from the mapping stays, so a map with one mapping per line keeps the columns. + current_state.original_column = + mapping.original.columns.zero_based().saturating_add( + current_state.original_column - mapping.generated.columns.zero_based(), + ); + } else { + current_state.source_index = NOT_IN_INPUT_SOURCE_MAP; + } + } self.append_mapping_without_remapping(current_state); } diff --git a/src/sourcemap/ParsedSourceMap.rs b/src/sourcemap/ParsedSourceMap.rs index 7e1454e7751f..4f9ad685ceb8 100644 --- a/src/sourcemap/ParsedSourceMap.rs +++ b/src/sourcemap/ParsedSourceMap.rs @@ -237,6 +237,19 @@ impl ParsedSourceMap { !self.external_source_names.is_empty() } + pub fn only_source_is(&self, path: &[u8]) -> bool { + use bun_paths::{platform::Auto, resolve_path}; + + let [name] = self.external_source_names.as_slice() else { + return false; + }; + bun_paths::is_absolute(path) + && resolve_path::join_abs_string_checked::( + resolve_path::dirname::(path), + &[name], + ) == Some(path) + } + pub fn find_mapping(&self, line: Ordinal, column: Ordinal) -> Option { if let Some(ism) = &self.internal { return ism.find(line, column); diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 47de3f994d80..5a52e44932bf 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -841,7 +841,9 @@ pub(crate) fn parse_url( else { break 'try_data_url; }; - if &after[..comma] != b"base64" { + // Babel and magic-string write `charset=utf-8;base64`. + let parameters = &after[..comma]; + if parameters != b"base64" && !parameters.ends_with(b";base64") { break 'try_data_url; } let base64_data = &after[comma + 1..]; @@ -866,6 +868,22 @@ pub(crate) fn parse_url( parse_json(json_bytes, hint) } +/// The inline source map of what an `onLoad` plugin returned for a file, if it is a map of that file. +pub fn parse_input_source_map(source: &bun_ast::Source) -> Option> { + let url = find_source_mapping_url_u8(source.contents())?; + let arena = bun_alloc::Arena::new(); + let map = parse_url(&arena, url.slice(), ParseUrlResultHint::MappingsOnly) + .ok()? + .map?; + if !map.only_source_is(source.path.text) { + return None; + } + // A map that the file ends with too says how the file was made, and the plugin passed it on. + let is_from_the_file = bun_sys::File::read_from(bun_sys::Fd::cwd(), source.path.text) + .is_ok_and(|file| find_source_mapping_url_u8(&file).is_some_and(|own| *own == *url)); + (!is_from_the_file).then_some(map) +} + /// Parses a JSON source-map /// /// `source` must be in UTF-8 and can be freed after this call. @@ -907,15 +925,12 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu return Err(crate::Error::InvalidSourceMap); }; - let sources_content = match json - .get(b"sourcesContent") - .ok_or(crate::Error::InvalidSourceMap)? - .data - { - bun_ast::ExprData::EArrayJSON(arr) => arr, - _ => return Err(crate::Error::InvalidSourceMap), + let sources_content = match json.get(b"sourcesContent").map(|contents| contents.data) { + Some(bun_ast::ExprData::EArrayJSON(arr)) => Some(arr), + Some(bun_ast::ExprData::ENull(_)) | None => None, + Some(_) => return Err(crate::Error::InvalidSourceMap), }; - let sources_content = sources_content.get(); + let sources_content = sources_content.as_ref().map(|arr| arr.get().items()); let sources_paths = match json .get(b"sources") @@ -927,15 +942,16 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu }; let sources_paths = sources_paths.get(); - if sources_content.items().len() != sources_paths.items().len() { + if sources_content.is_some_and(|contents| contents.len() != sources_paths.items().len()) { return Err(crate::Error::InvalidSourceMap); } + let sources_content = sources_content.unwrap_or_default(); let source_only = matches!(hint, ParseUrlResultHint::SourceOnly(_)); // `Vec>` drops automatically on error. let source_paths_slice: Option>> = if !source_only { - let mut v: Vec> = Vec::with_capacity(sources_content.items().len()); + let mut v: Vec> = Vec::with_capacity(sources_paths.items().len()); for item in sources_paths.items() { let Some(s) = item.as_str() else { return Err(crate::Error::InvalidSourceMap); @@ -1028,9 +1044,9 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu let content_slice: Option> = match source_index { Some(idx) if !matches!(hint, ParseUrlResultHint::MappingsOnly) - && (idx as usize) < sources_content.items().len() => + && (idx as usize) < sources_content.len() => 'content: { - let item = &sources_content.items()[idx as usize]; + let item = &sources_content[idx as usize]; let Some(str) = item.as_str() else { break 'content None; }; diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 47bfd13eaba8..ef845de539b5 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -7,7 +7,8 @@ use bun_collections::VecExt; use bun_collections::bit_set::DynamicBitSet; use bun_core::{self, Utf8Bytes}; use bun_jsc::{JSGlobalObject, JSValue, VM, bun_string_jsc}; -use bun_sourcemap::{Ordinal, ParsedSourceMap}; +use bun_sourcemap::chunk::NOT_IN_INPUT_SOURCE_MAP; +use bun_sourcemap::{Mapping, Ordinal, ParsedSourceMap}; type LinesHits = Vec; type Bitset = DynamicBitSet; @@ -687,6 +688,8 @@ impl ByteRangeMapping { .as_mut() .source_mappings() .get(source_url) + // The lines in the map of a bundle are lines of other files. + .filter(|map| !map.is_external() || map.only_source_is(source_url)) }; let mut blocks: Vec = @@ -718,6 +721,9 @@ impl ByteRangeMapping { if let Some(source_map) = source_map.as_deref() { let mut previous_original = None; source_map.for_each_mapping(|mapping| { + if is_added_to_the_file(&mapping) { + return; + } // The printer repeats a mapping at column 0 of a line that starts without one. let original = ( mapping.original.lines.zero_based(), @@ -752,7 +758,9 @@ impl ByteRangeMapping { if let Some(source_map) = source_map.as_deref() { functions.retain(|function| { let (line, column) = self.line_and_column_of(function.range.start); - source_map.find_mapping(line, column).is_some() + source_map + .find_mapping(line, column) + .is_some_and(|mapping| !is_added_to_the_file(&mapping)) }); } @@ -827,6 +835,11 @@ impl ByteRangeMapping { } } +/// Code that a plugin added to the file it rewrote: it has no line in the file. +fn is_added_to_the_file(mapping: &Mapping) -> bool { + mapping.source_index == NOT_IN_INPUT_SOURCE_MAP +} + /// `ByteRangeMapping::line_starts` and `executable_units`. Lines end where /// `bun_sourcemap::Chunk`'s builder ends a generated line. fn scan(units: impl ExactSizeIterator) -> (Vec, Bitset) { diff --git a/test/cli/inspect/inspect-inline-sourcemap.test.ts b/test/cli/inspect/inspect-inline-sourcemap.test.ts index 9d16f40145cd..5a309a3d4b71 100644 --- a/test/cli/inspect/inspect-inline-sourcemap.test.ts +++ b/test/cli/inspect/inspect-inline-sourcemap.test.ts @@ -2,20 +2,24 @@ import { spawn } from "bun"; import { expect, test } from "bun:test"; import fs from "fs"; import { bunEnv, bunExe, tempDir } from "harness"; +import { SourceMap } from "node:module"; import { join } from "node:path"; import { WebSocket } from "ws"; -test("--inspect inline sourcemap sources[0] is a valid path under cwd", async () => { - // VT (0x0B) / BEL (0x07) in the source exercise quote_for_json's RFC 8259 - // escape handling for sourcesContent. - const source = "// comment [\x0b\x07]\nsetInterval(() => {}, 200);\n"; +// VT (0x0B) / BEL (0x07) in the source exercise quote_for_json's RFC 8259 +// escape handling for sourcesContent. +const source = "// comment [\x0b\x07]\nsetInterval(() => {}, 200);\n"; + +/** The JSON of the source map the inspector gets for `sub/target.mjs`. */ +async function inlineSourceMap(files: Record, args: string[]) { using dir = tempDir("inspect-sourcemap", { "sub/target.mjs": source, + ...files, }); const cwd = fs.realpathSync(String(dir)); await using proc = spawn({ - cmd: [bunExe(), "--inspect-wait=127.0.0.1:0", join("sub", "target.mjs")], + cmd: [bunExe(), "--inspect-wait=127.0.0.1:0", ...args, join("sub", "target.mjs")], env: bunEnv, cwd, stdout: "ignore", @@ -80,13 +84,51 @@ test("--inspect inline sourcemap sources[0] is a valid path under cwd", async () const m = String(params.sourceMapURL ?? "").match(/base64,([A-Za-z0-9+/=]+)/); expect(m).not.toBeNull(); - const raw = Buffer.from(m![1], "base64").toString(); - expect(raw).not.toMatch(/\\v|\\x[0-9A-Fa-f]{2}/); - const map = JSON.parse(raw); - - expect(map.sources[0]).toBe("/sub/target.mjs"); - expect(map.sourcesContent[0]).toBe(source); + return Buffer.from(m![1], "base64").toString(); } finally { ws.close(); } +} + +test("--inspect inline sourcemap sources[0] is a valid path under cwd", async () => { + const raw = await inlineSourceMap({}, []); + expect(raw).not.toMatch(/\\v|\\x[0-9A-Fa-f]{2}/); + const map = JSON.parse(raw); + + expect(map.sources[0]).toBe("/sub/target.mjs"); + expect(map.sourcesContent[0]).toBe(source); +}); + +test("--inspect inline sourcemap is of what a plugin returned, when that ends with a source map", async () => { + const map = JSON.parse( + await inlineSourceMap( + { + "plugin.js": ` + Bun.plugin({ + name: "rewrite", + setup(build) { + build.onLoad({ filter: /target\\.mjs$/ }, async ({ path }) => { + // Each line of the file is three lines further down. + const map = { version: 3, sources: [path], names: [], mappings: ";;;AAAA;AACA" }; + return { + contents: + "// 1\\n// 2\\n// 3\\n" + + (await Bun.file(path).text()) + + "//# sourceMappingURL=data:application/json;base64," + + btoa(JSON.stringify(map)), + loader: "js", + }; + }); + }, + }); + `, + }, + ["--preload", "./plugin.js"], + ), + ); + + expect(map.sources[0]).toBe("/sub/target.mjs"); + expect(map.sourcesContent[0]).toStartWith("// 1\n// 2\n// 3\n" + source); + const entries = Array.from({ length: 8 }, (_, line) => new SourceMap(map).findEntry(line, 0).originalLine); + expect(entries.filter(line => line !== undefined)).toEqual([4]); }); diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 5ff9f071246d..1ee3ed858fe6 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -859,3 +859,29 @@ try { expectRemappedFrameAndStoredSourceMap(run()); }); }); + +test("leaves out what a plugin returned with a source map of the file", async () => { + writeFileSync(join(temp_dir, "big.js"), ""); + writeFileSync( + join(temp_dir, "entry.js"), + ` + const map = { version: 3, sources: ["big.js"], names: [], mappings: "AAAA" }; + Bun.plugin({ + name: "big", + setup(build) { + build.onLoad({ filter: /big\\.js$/ }, () => ({ + contents: + "console.log('big'); //" + Buffer.alloc(50 * 1024, "/") + "\\n" + + (process.env.WITH_MAP ? "//# sourceMappingURL=data:application/json;base64," + btoa(JSON.stringify(map)) : ""), + loader: "js", + })); + }, + }); + await import("./big.js"); + `, + ); + expect(await bunRun(join(temp_dir, "entry.js"), env)).toSpawn("big"); + expect(newCacheCount()).toBe(1); + expect(await bunRun(join(temp_dir, "entry.js"), { ...env, WITH_MAP: "1" })).toSpawn("big"); + expect(newCacheCount()).toBe(0); +}); diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index 4867fe9df426..a9a9613a85cd 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -1130,3 +1130,313 @@ test("reports an error for a coverage directory longer than the path buffer", as expect(stderr).toContain("ENAMETOOLONG: File name too long: Failed to write lcov.info"); expect(exitCode).toBe(1); }); + +describe("a module that ends with an inline source map", () => { + const moved = `export function never() { + return 2; +} +export function ran() { + return [ + { one: 1 }, + ]; +} +export function throws() { + throw new Error("here"); +} +`; + const injected = "globalThis.injected = function injected() { return new Error().stack; };\n"; + + type Rewrite = { + /** `sources[0]`. The absolute path of the file when it is `null`. */ + source: string | null; + /** `sources[1]`. */ + secondSource?: string; + sourcesContent?: boolean | null; + mediaType?: string; + /** Lines above the text, which the map does not cover. */ + above?: string; + /** One mapping for each character of the file, or one for each line. */ + mappings?: "character" | "line"; + }; + + /** `above`, then the text with four spaces before each of its lines. */ + function rewrite( + text: string, + { + source, + secondSource, + sourcesContent = true, + mediaType = "", + above = "// 1\n// 2\nglobalThis.injected = function injected() { return new Error().stack; };\n", + mappings = "character", + }: Rewrite, + ) { + const digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + function vlq(n: number) { + let rest = n < 0 ? (-n << 1) | 1 : n << 1; + let out = ""; + do { + const digit = rest & 31; + rest >>>= 5; + out += digits[rest ? digit | 32 : digit]; + } while (rest); + return out; + } + const lines = text.split("\n"); + let column = 0; + const sources = secondSource ? [source, secondSource] : [source]; + const map = { + version: 3, + sources, + sourcesContent: sourcesContent === null ? null : sourcesContent ? sources.map(() => text) : undefined, + names: [], + mappings: + ";".repeat(above.split("\n").length - 1) + + lines + .map((line, i) => + Array.from(mappings === "line" ? line.slice(0, 1) : line, (_, j) => { + const segment = vlq(j ? 1 : 4) + "A" + vlq(j || !i ? 0 : 1) + vlq(j - column); + column = j; + return segment; + }).join(","), + ) + .join(";"), + }; + return `${above}${lines.map(line => " " + line).join("\n")} +//# sourceMappingURL=data:application/json${mediaType};base64,${btoa(JSON.stringify(map))}`; + } + + /** `plugin` is what a plugin does to `moved.ts`. */ + function files(movedOnDisk: string, plugin?: Rewrite | "nothing") { + return tempDir("cov-input-source-map", { + "moved.ts": movedOnDisk, + "plugin.js": ` + ${rewrite} + Bun.plugin({ + name: "rewrite", + setup(build) { + const options = ${JSON.stringify(plugin)}; + build.onLoad({ filter: /moved\\.ts$/ }, async ({ path }) => { + const text = await Bun.file(path).text(); + return { + contents: options === "nothing" ? text : rewrite(text, { ...options, source: options.source ?? path }), + loader: "ts", + }; + }); + }, + }); + `, + "other.ts": "", + "uncaught.ts": `import { throws } from "./moved"; throws();`, + "moved.test.ts": ` + import { ran, throws } from "./moved"; + test("moved", () => { + ran(); + try { + throws(); + } catch (e) { + console.log("thrown at", /moved\\.ts:\\d+:\\d+/.exec(e.stack)[0]); + } + console.log("injected at", /moved\\.ts:\\d+:\\d+/.exec(globalThis.injected?.())?.[0]); + }); + `, + }); + } + + /** Without `plugin` there is no plugin. */ + async function report(movedOnDisk: string, plugin?: Rewrite | "nothing") { + using dir = files(movedOnDisk, plugin); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "test", + ...(plugin ? ["--preload", "./plugin.js"] : []), + "--coverage", + "--coverage-reporter=lcov", + "moved.test.ts", + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const lcov = readFileSync(path.join(String(dir), "coverage", "lcov.info"), "utf8"); + return { + positions: stdout.match(/^(thrown|injected) at .*$/gm)?.join(", "), + lines: lcov + .slice(lcov.indexOf("SF:moved.ts")) + .split("end_of_record")[0] + .match(/^(DA|FNF|FNH):.*$/gm) + ?.join(" "), + exitCode, + }; + } + + const inTheFile = { + positions: "thrown at moved.ts:10:13, injected at moved.ts:3:56", + lines: "FNF:3 FNH:2 DA:1,0 DA:2,0 DA:4,1 DA:5,1 DA:6,1 DA:7,1 DA:9,1 DA:10,1", + exitCode: 0, + }; + const inTheText = { + positions: "thrown at moved.ts:13:17, injected at moved.ts:3:56", + lines: "FNF:4 FNH:3 DA:3,1 DA:4,0 DA:5,0 DA:7,1 DA:8,1 DA:9,1 DA:10,1 DA:12,1 DA:13,1", + exitCode: 0, + }; + + test.concurrent("the file without a plugin", async () => { + expect(await report(moved)).toEqual({ + ...inTheFile, + positions: "thrown at moved.ts:10:13, injected at undefined", + }); + }); + + test.concurrent.each<[string, Rewrite]>([ + ["the name of the file", { source: "moved.ts" }], + ["the name of the file after ./", { source: "./moved.ts" }], + ["the absolute path of the file", { source: null }], + ["no sourcesContent", { source: "moved.ts", sourcesContent: false }], + ["sourcesContent: null", { source: "moved.ts", sourcesContent: null }], + ["charset=utf-8", { source: "moved.ts", mediaType: ";charset=utf-8" }], + ["one mapping for each line", { source: "moved.ts", mappings: "line" }], + ])("from a plugin, of the file: %s", async (_, plugin) => { + expect(await report(moved, plugin)).toEqual(inTheFile); + }); + + test.concurrent.each(["character", "line"] as const)( + "from a plugin, of the file: an inline snapshot, with one mapping for each %s", + async mappings => { + const text = (snapshot: string) => + `import { expect, test } from "bun:test";\ntest("snapshot", () => {\n expect(1).toMatchInlineSnapshot(${snapshot});\n});\n`; + using dir = files(text(""), { source: "moved.ts", mappings }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--preload", "./plugin.js", "--update-snapshots", "./moved.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "inherit", + }); + const exitCode = await proc.exited; + expect(readFileSync(path.join(String(dir), "moved.ts"), "utf8")).toBe(text("`1`")); + expect(exitCode).toBe(0); + }, + ); + + test.concurrent("from a plugin, of the file: a text that Bun does not transpile", async () => { + expect(await report(moved, { source: "moved.ts", above: "// @bun\n// 2\n" + injected })).toEqual({ + positions: "thrown at moved.ts:10:18, injected at moved.ts:3:61", + lines: "FNF:3 FNH:2 DA:1,1 DA:2,0 DA:4,1 DA:5,1 DA:6,1 DA:7,1 DA:9,1 DA:10,1", + exitCode: 0, + }); + }); + + test.concurrent("from a plugin, of the file: one statement above a line with one statement", async () => { + const text = `import "./other";\n${moved}`; + const expected = { + positions: "thrown at moved.ts:11:13, injected at undefined", + lines: "FNF:3 FNH:2 DA:1,1 DA:2,0 DA:3,0 DA:5,1 DA:6,1 DA:7,1 DA:8,1 DA:10,1 DA:11,1", + exitCode: 0, + }; + expect(await report(text)).toEqual(expected); + expect(await report(text, { source: "moved.ts", above: `import "./other";\n` })).toEqual(expected); + }); + + test.concurrent("from a plugin, of the file: the preview of an error", async () => { + using dir = files(moved, { source: "moved.ts" }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preload", "./plugin.js", "uncaught.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain( + ` 9 | export function throws() {\n10 | throw new Error("here");\n ^\nerror: here`, + ); + expect(exitCode).toBe(1); + }); + + test.concurrent.each<[string, Rewrite]>([ + ["another file", { source: "another.ts" }], + ["the file and another file", { source: "moved.ts", secondSource: "another.ts" }], + ])("from a plugin, of %s", async (_, plugin) => { + expect(await report(moved, plugin)).toEqual(inTheText); + }); + + test.concurrent("from a plugin, which the file ends with too", async () => { + expect(await report(rewrite(moved, { source: "moved.ts" }), "nothing")).toEqual(inTheText); + }); + + test.concurrent("from a plugin, of a module that is not a file", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const map = { version: 3, sources: [""], names: [], mappings: "AAAA" }; + Bun.plugin({ + name: "virtual", + setup(build) { + build.module("virtual", () => ({ + contents: "export default 1;\\n//# sourceMappingURL=data:application/json;base64," + btoa(JSON.stringify(map)), + loader: "js", + })); + }, + }); + console.log((await import("virtual")).default); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("1\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("from bun build: the lines are those of the bundle", async () => { + async function records(sourcemap: "none" | "inline") { + using dir = tempDir("cov-bundle-source-map", { + "a.ts": "export function a1() {\n return 1;\n}\nexport function a2() {\n return 2;\n}\n", + "b.ts": "export function b1() {\n return 1;\n}\nexport function b2() {\n return 2;\n}\n", + "index.ts": `export * from "./a";\nexport * from "./b";\n`, + "bundle.test.ts": ` + import { a1, b1 } from "./dist/index.js"; + test("bundle", () => { + a1(); + b1(); + }); + `, + }); + for (const args of [ + ["build", "index.ts", "--outdir", "dist", "--target=bun", "--sourcemap=" + sourcemap], + ["test", "--coverage", "--coverage-reporter=lcov", "bundle.test.ts"], + ]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "inherit", + }); + expect(await proc.exited).toBe(0); + } + const lcov = readFileSync(path.join(String(dir), "coverage", "lcov.info"), "utf8"); + return lcov + .slice(lcov.indexOf("SF:dist")) + .split("end_of_record")[0] + .match(/^(DA|FNF|FNH):.*$/gm); + } + const [none, inline] = await Promise.all([records("none"), records("inline")]); + expect(none).toContain("FNF:4"); + expect(none).toContain("FNH:2"); + // The bundle with a source map has two more lines at the end, which are comments. + expect(inline!.slice(0, none!.length)).toEqual(none); + }); + + test.concurrent("from the file itself", async () => { + expect(await report(rewrite(moved, { source: "moved.ts" }))).toEqual(inTheText); + }); +}); diff --git a/test/js/bun/sourcemap/negative-vlq-mapping.test.ts b/test/js/bun/sourcemap/negative-vlq-mapping.test.ts index 746ff863712c..07464b0f53bd 100644 --- a/test/js/bun/sourcemap/negative-vlq-mapping.test.ts +++ b/test/js/bun/sourcemap/negative-vlq-mapping.test.ts @@ -169,3 +169,63 @@ describe.concurrent("sourcemap: negative VLQ delta in mappings is rejected, not expect(exitCode).toBe(0); }); }); + +describe.concurrent("sourcemap: a map without sourcesContent", () => { + // The line of `body` that throws is line 7, column 4 of orig.ts. + const map = JSON.stringify({ version: 3, sources: ["orig.ts"], names: [], mappings: ";;AAMG" }); + const inline = (parameters: string) => ({ + "entry.js": body + `//# sourceMappingURL=data:application/json${parameters},${btoa(map)}\n`, + }); + + test.each([ + ["external .map", { "entry.js": body + `//# sourceMappingURL=entry.js.map\n`, "entry.js.map": map }], + ["inline data-URL map", inline(";base64")], + ["inline data-URL map with a charset", inline(";charset=utf-8;base64")], + ])("%s", async (_, files) => { + using dir = tempDir("no-sources-content", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, stdout: stdout.trim() }).toEqual({ + stderr: "", + stdout: `at t (${join(String(dir), "orig.ts")}:7:4)`, + }); + expect(exitCode).toBe(0); + }); +}); + +test("sourcemap: the last column there is, in the map of what a plugin returned", async () => { + using dir = tempDir("last-column", { + "a.js": "", + "plugin.js": ` + const map = { version: 3, sources: ["a.js"], names: [], mappings: "AAA+/////D" }; + Bun.plugin({ + name: "last column", + setup(build) { + build.onLoad({ filter: /a.js$/ }, () => ({ + contents: [ + "console.log(new Error().stack.split(String.fromCharCode(10))[1].trim());", + "//# sourceMappingURL=data:application/json;base64," + btoa(JSON.stringify(map)), + ].join(String.fromCharCode(10)), + loader: "js", + })); + }, + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preload", "./plugin.js", "a.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toStartWith(`at ${join(String(dir), "a.js")}:1:`); + expect(exitCode).toBe(0); +}); diff --git a/test/js/third_party/svelte/bun-loader-svelte-source-map.ts b/test/js/third_party/svelte/bun-loader-svelte-source-map.ts new file mode 100644 index 000000000000..c960b6450a89 --- /dev/null +++ b/test/js/third_party/svelte/bun-loader-svelte-source-map.ts @@ -0,0 +1,15 @@ +import { plugin } from "bun"; +import { compile } from "svelte/compiler"; + +plugin({ + name: "svelte loader with a source map", + setup(builder) { + builder.onLoad({ filter: /\.svelte$/ }, async ({ path }) => { + const { js } = compile(await Bun.file(path).text(), { filename: path, generate: "server", dev: false }); + return { + contents: `${js.code}\n//# sourceMappingURL=${js.map.toUrl()}`, + loader: "js", + }; + }); + }, +}); diff --git a/test/js/third_party/svelte/svelte.test.ts b/test/js/third_party/svelte/svelte.test.ts index 05a56e4c42e5..a7456c1a8157 100644 --- a/test/js/third_party/svelte/svelte.test.ts +++ b/test/js/third_party/svelte/svelte.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe } from "harness"; import { render as svelteRender } from "svelte/server"; import "./bun-loader-svelte"; @@ -40,3 +41,30 @@ describe("dynamic import", () => { expect(body).toBe("

Hello world!

"); }); }); + +it("has the positions in the .svelte file in error.stack, with Svelte's source map", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--preload", + "./bun-loader-svelte-source-map.ts", + "-e", + ` + import { render } from "svelte/server"; + import App from "./throws.svelte"; + try { + render(App); + } catch (e) { + console.log(e.stack.match(/throws\\.svelte:\\d+:\\d+/g).join(" ")); + } + `, + ], + env: bunEnv, + cwd: import.meta.dir, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("throws.svelte:4:15 throws.svelte:6:3\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/third_party/svelte/throws.svelte b/test/js/third_party/svelte/throws.svelte new file mode 100644 index 000000000000..8cea42e09ab0 --- /dev/null +++ b/test/js/third_party/svelte/throws.svelte @@ -0,0 +1,9 @@ + + +

Hello {name}!