diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index b9a1837b9a10..3ba445007d83 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -760,12 +760,14 @@ Specifies the type of sourcemap to generate. | Value | Description | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"none"` | Default. No sourcemap is generated. | -| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` to be set. You can customize the base URL in this comment with `--public-path`.

`js
//

//# sourceMappingURL=bundle.js.map
` | +| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` to be set. You can customize the base URL in this comment with `--public-path`.

`js
//

//# debugId=
//# sourceMappingURL=bundle.js.map
` | | `"external"` | A separate `*.js.map` file is created alongside each `*.js` bundle without inserting a `//# sourceMappingURL` comment.

Generated bundles contain a debug id that can be used to associate a bundle with its corresponding sourcemap. This `debugId` is added as a comment at the bottom of the file.

`js
//

//# debugId=
` | -| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.

`js
//

//# sourceMappingURL=data:application/json;base64,
` | +| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.

`js
//

//# debugId=
//# sourceMappingURL=data:application/json;base64,
` | The associated `*.js.map` sourcemap is a JSON file containing an equivalent `debugId` property. +Bun reads the `*.js.map` file beside a bundle built with [`target: "bun"`](#target) the first time it needs a stack trace for that bundle. If the `debugId` in the `*.js.map` file differs from the `//# debugId` comment of the bundle, the map is from another build. Bun prints a warning and does not use the map. Bun does not compare a map that has no `debugId`. + ### minify Whether to enable minification. Default `false`. diff --git a/src/sourcemap/debug_id.rs b/src/sourcemap/debug_id.rs new file mode 100644 index 000000000000..5a477a4abafa --- /dev/null +++ b/src/sourcemap/debug_id.rs @@ -0,0 +1,122 @@ +//! The debug id of a bundle: `bun build` writes it into the file (`//# debugId=`) and into its map (`"debugId"`). + +use bun_core::strings::{self, PathByte}; +use bun_core::{Utf8Bytes, fmt}; +use core::fmt::{Display, Formatter}; + +pub(crate) struct Check<'a> { + pub(crate) file_id: &'a [u8], + pub(crate) source_filename: &'a [u8], +} + +impl Check<'_> { + /// A UUID is 32 hex digits, written with or without dashes and in either case. Any other id is compared as it is. + pub(crate) fn is_id_of_file(&self, map_id: &[u8]) -> bool { + fn digits(id: &[u8]) -> impl Iterator + '_ { + id.iter() + .filter(|unit| **unit != b'-') + .map(u8::to_ascii_lowercase) + } + let is_uuid = |id: &[u8]| { + digits(id).count() == 32 && digits(id).all(|digit| digit.is_ascii_hexdigit()) + }; + self.file_id == map_id || (is_uuid(self.file_id) && digits(self.file_id).eq(digits(map_id))) + } + + #[cold] + pub(crate) fn warn(&self, map_id: &[u8]) { + let map_path = [self.source_filename, b".map"].concat(); + bun_core::warn!( + "Ignoring sourcemap {}: its debugId {} is not the debugId {} that {} had when it was last loaded", + fmt::EscapeControlChars(fmt::quote(&map_path)), + Shown(map_id), + Shown(self.file_id), + fmt::EscapeControlChars(fmt::quote(self.source_filename)), + ); + bun_core::note!( + "positions in this file stay unmapped until Bun loads it again beside a map with the same debugId" + ); + // `note!` does not flush, and a Worker can end with no other flush. + bun_core::output::flush(); + crate::SavedSourceMap::MissingSourceMapNoteInfo::set_seen_invalid(true); + } +} + +struct Shown<'a>(&'a [u8]); + +impl Shown<'_> { + const MAX: usize = 64; +} + +impl Display for Shown<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + let shown = &self.0[..self.0.len().min(Self::MAX)]; + write!(f, "{}", fmt::EscapeControlChars(fmt::quote(shown)))?; + if shown.len() < self.0.len() { + f.write_str("...")?; + } + Ok(()) + } +} + +/// The id on the `//# debugId=` line of a `// @bun` file: the last line, or the line above a last `//# sourceMappingURL=` line. +pub(crate) fn of_file(source: &bun_core::String) -> Option> { + let id = if source.is_8bit() { + Utf8Bytes::Borrowed(value_of_id_line(source.latin1())?) + } else { + Utf8Bytes::Owned(strings::to_utf8_alloc(value_of_id_line(source.utf16())?)) + }; + // An id is one run of ASCII with no blank and no quote: V8 reads the value of a magic comment this way. + let is_id = !id.is_empty() + && id + .iter() + .all(|unit| unit.is_ascii_graphic() && !matches!(*unit, b'"' | b'\'')); + is_id.then_some(id) +} + +fn value_of_id_line(text: &[T]) -> Option<&[T]> { + // Text that does not start as the file does is not the file: for a replaced `Module.wrapper` the loader swaps the first line of a CommonJS file and cuts its end. + let first_line = match strip_prefix(text, b"#!") { + Some(_) => &text[T::index_of_unit(text, T::from_u8(b'\n'))? + 1..], + None => text, + }; + strip_prefix(first_line, b"// @bun")?; + + let (above, last) = split_last_line(text); + let line = match strip_prefix(last, b"//# sourceMappingURL=") { + Some(_) => split_last_line(above).1, + None => last, + }; + let mut value = strip_prefix(line, b"//# debugId=")?; + let blank = [T::from_u8(b' '), T::from_u8(b'\t')]; + while let [first, rest @ ..] = value { + if !blank.contains(first) { + break; + } + value = rest; + } + Some(value) +} + +fn split_last_line(mut text: &[T]) -> (&[T], &[T]) { + let blank = (*b"\n\r \t").map(T::from_u8); + while let [rest @ .., last] = text { + if !blank.contains(last) { + break; + } + text = rest; + } + match strings::last_index_of_char_t(text, T::from_u8(b'\n')) { + Some(line_end) => (&text[..line_end], &text[line_end + 1..]), + None => (&text[..0], text), + } +} + +fn strip_prefix<'a, T: PathByte>(text: &'a [T], prefix: &[u8]) -> Option<&'a [T]> { + let (start, rest) = text.split_at_checked(prefix.len())?; + start + .iter() + .zip(prefix) + .all(|(unit, byte)| *unit == T::from_u8(*byte)) + .then_some(rest) +} diff --git a/src/sourcemap/error.rs b/src/sourcemap/error.rs index 75ee0e57a5d0..cecf9c0cf2eb 100644 --- a/src/sourcemap/error.rs +++ b/src/sourcemap/error.rs @@ -28,6 +28,9 @@ pub enum Error { UnsupportedVersion, #[error("InvalidSourceMap")] InvalidSourceMap, + /// A `.map` with the debug id of another build. `get_source_map_impl` gives no map for it. + #[error("DebugIdMismatch")] + DebugIdMismatch, #[error(transparent)] Alloc(#[from] bun_alloc::AllocError), #[error(transparent)] @@ -52,6 +55,7 @@ impl Error { Self::InvalidJSON => "Invalid source map JSON", Self::UnsupportedVersion => "Unsupported source map version", Self::InvalidSourceMap => "Invalid source map", + Self::DebugIdMismatch => "Source map is from another build", Self::Alloc(_) => "Out of memory", Self::Core(e) => e.name(), } @@ -74,6 +78,7 @@ impl Error { Self::InvalidJSON => "InvalidJSON", Self::UnsupportedVersion => "UnsupportedVersion", Self::InvalidSourceMap => "InvalidSourceMap", + Self::DebugIdMismatch => "DebugIdMismatch", Self::Alloc(_) => "OutOfMemory", Self::Core(e) => e.name(), } diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index e4ba62b09d68..f577818ad90b 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -11,6 +11,7 @@ pub use error::{Error, Result}; #[path = "Chunk.rs"] pub mod chunk; +mod debug_id; #[path = "InternalSourceMap.rs"] pub mod internal_source_map; #[path = "LineOffsetTable.rs"] @@ -535,8 +536,11 @@ pub(crate) fn get_source_map_impl( Err(_) => break 'try_external, }; - match parse_json(&data, result) { + let source = provider.get_source_slice(); + match parse_sidecar_json(&data, result, &source, source_filename) { Ok(parsed) => break 'parsed (SourceMapLoadHint::IsExternalMap, parsed), + // The warning is in `parse_json_impl`, which has the id of the map. + Err(crate::Error::DebugIdMismatch) => return None, Err(err) => { // Print warning even if this came from non-visible code like // calling `error.stack`. This message is only printed if @@ -870,6 +874,29 @@ pub(crate) fn parse_url( /// `source` must be in UTF-8 and can be freed after this call. /// The mappings are owned by the global allocator. pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Result { + parse_json_impl(source, hint, None) +} + +/// `parse_json` for `.map`: `Error::DebugIdMismatch` for a map with another debug id than `source`. +fn parse_sidecar_json( + data: &[u8], + hint: ParseUrlResultHint, + source: &bun_core::String, + source_filename: &[u8], +) -> crate::Result { + let file_id = debug_id::of_file(source); + let check = file_id.as_deref().map(|file_id| debug_id::Check { + file_id, + source_filename, + }); + parse_json_impl(data, hint, check.as_ref()) +} + +fn parse_json_impl( + source: &[u8], + hint: ParseUrlResultHint, + check: Option<&debug_id::Check<'_>>, +) -> crate::Result { use crate::mapping::SourceMap as SourceMapLog; use bun_ast::StoreResetGuard as DataStoreScope; use std::sync::Arc; @@ -890,6 +917,20 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu Err(_) => return Err(crate::Error::InvalidJSON), }; let json = parsed.root; + let source_only = matches!(hint, ParseUrlResultHint::SourceOnly(_)); + + if let Some(check) = check + && let Some(map_id) = json.get(b"debugId") + && let Some(map_id) = map_id.as_utf8_string_literal() + && !map_id.is_empty() + && !check.is_id_of_file(map_id) + { + // A `SourceOnly` read follows a lookup that applied the map, so the map on disk changed after that. + if !source_only { + check.warn(map_id); + } + return Err(crate::Error::DebugIdMismatch); + } if let Some(version) = json.get(b"version") { match version.data.as_e_number() { @@ -930,8 +971,6 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu return Err(crate::Error::InvalidSourceMap); } - 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()); diff --git a/test/aot/runner-suites.known.txt b/test/aot/runner-suites.known.txt index 4be569aaf71d..fc6066b7d448 100644 --- a/test/aot/runner-suites.known.txt +++ b/test/aot/runner-suites.known.txt @@ -30,6 +30,7 @@ test/cli/test/bun-test.test.ts: a test file that can never finish loading > the test/cli/test/coverage.test.ts: a file loaded more than once counts every load > (unnamed) test/cli/test/coverage.test.ts: a file with ? in its name is not left out of coverage: it cannot be loaded test/cli/test/coverage.test.ts: both braces of an object are listed and no brace of a block is: // @bun with a source map +test/cli/test/coverage.test.ts: coverage of a // @bun file uses the map beside it unless the map has another debug id # The next one imports a macro. A macro stops the build of a compiled run with TS100053, as docs/compiler/compatibility.mdx says. test/cli/test/coverage.test.ts: objects that are not in the file do not move the lines that are listed # The next one cannot pass: it waits until `isolatedModuleCacheSourceType()` of bun:internal-for-testing finds a file in the cache of sources that --isolate keeps, and a compiled file never gets there. Its child run never ends. diff --git a/test/bundler/bundler_bun.test.ts b/test/bundler/bundler_bun.test.ts index 08f86e9d1216..77a2efc981f5 100644 --- a/test/bundler/bundler_bun.test.ts +++ b/test/bundler/bundler_bun.test.ts @@ -1,5 +1,6 @@ import { Database } from "bun:sqlite"; import { describe, expect } from "bun:test"; +import { mkdirSync } from "node:fs"; import { itBundled } from "./expectBundled"; const nestedFunctions = /* js */ ` @@ -167,6 +168,75 @@ error: Hello World`, }, }, }); + // The bundle and its map have one debug id. Each case runs the pair as it was built, then the bundle beside a map + // with another id. + for (const format of ["esm", "cjs"] as const) { + for (const [sourceMap, publicPath] of [ + ["external"], + ["linked"], + ["linked", "https://example.com/assets/"], + ] as const) { + const name = `bun/TargetBunSourcemapDebugId-${format}-${sourceMap}${publicPath ? "-public-path" : ""}`; + itBundled(name, () => { + const ids = { bundle: "", map: "" }; + const warnings = (stderr: string) => + stderr.split("\n").filter(line => line.startsWith("warn: ") || line.startsWith("note: ")); + return { + target: "bun", + format, + outdir: "/out", + sourceMap, + publicPath, + files: { + "/entry.ts": /* js */ ` + function code() { + throw new Error("Hello World"); + } + code(); + `, + }, + onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + ids.bundle = map.debugId; + ids.map = "0000000000000000" + map.debugId.slice(16); + const bundle = api.readFile("/out/entry.js"); + expect(bundle).toInclude(`\n//# debugId=${ids.bundle}\n`); + mkdirSync(api.join("other"), { recursive: true }); + api.writeFile("/other/entry.js", bundle); + api.writeFile("/other/entry.js.map", JSON.stringify({ ...map, debugId: ids.map, sources: ["other.ts"] })); + }, + run: [ + { + file: "/out/entry.js", + exitCode: 1, + validate({ stderr }) { + expect(warnings(stderr)).toEqual([]); + expect(stderr).toInclude("error: Hello World"); + expect(stderr).toContain("entry.ts:2:9)"); + }, + }, + { + file: "/other/entry.js", + exitCode: 1, + validate({ stderr }) { + expect(warnings(stderr)).toEqual([ + expect.stringMatching( + new RegExp( + `^warn: Ignoring sourcemap ".*entry\\.js\\.map": its debugId "${ids.map}" is not the debugId "${ids.bundle}" that ".*entry\\.js" had when it was last loaded$`, + ), + ), + "note: positions in this file stay unmapped until Bun loads it again beside a map with the same debugId", + ]); + expect(stderr).toInclude("error: Hello World"); + expect(stderr).toInclude("entry.js:"); + expect(stderr).not.toInclude("other.ts"); + }, + }, + ], + }; + }); + } + } itBundled("bun/unicode comment", { target: "bun", files: { diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index 51502de2e407..f0c2d1b0b20d 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -1247,6 +1247,68 @@ test("reports an uncalled function on the last line of a // @bun file without a expect(exitCode).toBe(0); }); +// `bun build --sourcemap=external` writes one debug id into the file and into the map beside it. +test.concurrent("coverage of a // @bun file uses the map beside it unless the map has another debug id", async () => { + const fileId = "0123456789ABCDEF64756E2164756E21"; + const otherId = "FEDCBA987654321064756E2164756E21"; + const prebuilt = `// @bun\nexport function first() {\n return 1;\n}\nexport const second = () => 2;\n//# debugId=${fileId}\n`; + // Line 1 of the file is line 11 of the source, and so on. The one source of each map is the file itself. + const map = (source: string, debugId: string) => + JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: [null], + names: [], + mappings: "AAUA;AACA;AACA;AACA;AACA;AACA", + debugId, + }); + using dir = tempDir("cov-debug-id", { + "same-id.js": prebuilt, + "same-id.js.map": map("same-id.js", fileId), + "other-id.js": prebuilt, + "other-id.js.map": map("other-id.js", otherId), + "a.test.ts": ` + import { expect, test } from "bun:test"; + import * as sameId from "./same-id.js"; + import * as otherId from "./other-id.js"; + test("calls first", () => { expect(sameId.first() + otherId.first()).toBe(2); }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--coverage", "--coverage-reporter=lcov"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass"); + const lcov = readFileSync(path.join(String(dir), "coverage", "lcov.info"), "utf-8"); + const linesOf = (file: string) => { + const lines = lcov.split("\n").filter(line => line.startsWith("SF:") || line.startsWith("DA:")); + const start = lines.indexOf("SF:" + file) + 1; + const end = lines.findIndex((line, index) => index >= start && line.startsWith("SF:")); + return lines.slice(start, end === -1 ? undefined : end); + }; + expect({ + sameId: linesOf("same-id.js"), + otherId: linesOf("other-id.js"), + warnings: stderr.split("\n").filter(line => line.startsWith("warn: ") || line.startsWith("note: ")), + }).toEqual({ + sameId: ["DA:12,1", "DA:15,1", "DA:16,1"], + otherId: ["DA:2,1", "DA:3,1", "DA:5,1", "DA:6,1"], + warnings: [ + expect.stringMatching( + new RegExp( + `^warn: Ignoring sourcemap ".*other-id\\.js\\.map": its debugId "${otherId}" is not the debugId "${fileId}" that ".*other-id\\.js" had when it was last loaded$`, + ), + ), + "note: positions in this file stay unmapped until Bun loads it again beside a map with the same debugId", + ], + }); + expect(exitCode).toBe(0); +}); + // `braces.ts` in `source-positions-corpus.ts` has this for a file that Bun transpiles and reports by its source map. test.concurrent.each(["// @bun", "// @bun with a source map", "coverageIgnoreSourcemaps"])( "both braces of an object are listed and no brace of a block is: %s", diff --git a/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts b/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts index 7bbdf6465c18..ff5510ab4aa5 100644 --- a/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts +++ b/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts @@ -155,3 +155,288 @@ test("prints the stack trace when a `sources` entry is longer than a path buffer expect(stderr).toContain(`at t (${join(String(dir), sourceName)}:`); expect(exitCode).toBe(1); }); + +// `bun build --sourcemap=external` and `--sourcemap=linked` write one debug id into the bundle (`//# debugId=`) and +// into its map (`"debugId"`). A map beside the file that has another id is the map of another build. +// ";;EAIE" maps generated 3:3 to original 5:3 of source 0. +describe.concurrent("the debug id of a // @bun file and of the .map beside it", () => { + const throwsOnLine3 = `// @bun\nexport function t() {\n throw new Error("SC");\n}\n`; + const cjsThrowsOnLine3 = `// @bun @bun-cjs\n(function(exports, require, module, __filename, __dirname) {exports.t = function t() {\n throw new Error("SC");\n};\n})\n`; + const fileId = "0123456789ABCDEF64756E2164756E21"; + const otherId = "FEDCBA987654321064756E2164756E21"; + const uuid = "01234567-89ab-cdef-6475-6e2164756e21"; + const longId = Buffer.alloc(64, "a").toString(); + const map = (extra: object) => + JSON.stringify({ + version: 3, + sources: ["mapped.ts"], + sourcesContent: [null], + names: [], + mappings: ";;EAIE", + ...extra, + }); + const external = (id = fileId) => `//# debugId=${id}\n`; + const url = (name: string) => `//# sourceMappingURL=${name}.map\n`; + + async function run(files: Record, script: string, args: string[]) { + using dir = tempDir("sourcemap-debug-id", { "main.js": script, ...files }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A path in a warning is in quotes, and a quoted path that is not ASCII has each backslash twice. + const clean = (text: string) => + text.replaceAll("\\\\", "\\").replaceAll(String(dir), "").replaceAll("\\", "/"); + return { stdout: clean(stdout), stderr: clean(stderr), exitCode }; + } + // A warning shows the first 64 bytes of an id. + const shown = (id: string) => (id.length > 64 ? `"${id.slice(0, 64)}"...` : `"${id}"`); + const refused = (name: string, id = fileId, mapId = otherId) => + `warn: Ignoring sourcemap "/${name}.map": its debugId ${shown(mapId)} is not the debugId ${shown(id)} that "/${name}" had when it was last loaded\n` + + "note: positions in this file stay unmapped until Bun loads it again beside a map with the same debugId\n"; + const mapped = "at t (/mapped.ts:5:3)"; + const frameOf = `const frame = text => text.split("\\n").find(line => line.includes(" at t ")).trim();`; + + test("the map is read unless it has another debug id, and one warning says so", async () => { + // [name of the file, what follows its code, the map, the id that the file has when the map has another one] + const rows: [string, (name: string) => string, object, string?][] = [ + ["external.js", () => external(), { debugId: fileId }], + ["linked.js", name => external() + url(name), { debugId: fileId }], + ["required-external.cjs", () => external(), { debugId: fileId }], + ["required-linked.cjs", name => external() + url(name), { debugId: fileId }], + // The id is a UUID. Tools write it in lower case and with dashes. + ["uuid-in-map.js", () => external(), { debugId: uuid }], + ["uuid-in-file.js", () => external(uuid), { debugId: fileId }], + ["id-of-another-tool.js", name => external("abc_def") + url(name), { debugId: "abc_def" }], + ["long-id.js", () => external(longId + "1"), { debugId: longId + "1" }], + ["blank-before-id.js", () => external(" \t" + fileId), { debugId: fileId }], + // A tool that writes the map again can drop the id. + ["map-without-id.js", () => external(), {}], + ["map-with-empty-id.js", name => external() + url(name), { debugId: "" }], + ["map-id-is-not-a-string.js", () => external(), { debugId: 123 }], + // A file with no id line has nothing to compare. + ["file-without-id.js", name => url(name), { debugId: otherId }], + ["empty-id.js", () => external(""), { debugId: otherId }], + // An id is one run of ASCII characters with no blank and no quote. + ["text-after-id.js", () => external(otherId + " text"), { debugId: fileId }], + ["quoted-id.js", () => external(`"${otherId}"`), { debugId: fileId }], + ["id-is-not-ascii.js", () => external("caf\u00e9"), { debugId: otherId }], + // An id line is the last line, or the line above a last `//# sourceMappingURL=` line. + ["id-above-a-comment.js", () => external(otherId) + "// end\n", { debugId: fileId }], + ["id-two-lines-above-the-url.js", name => external(otherId) + "// x\n" + url(name), { debugId: fileId }], + ["indented-id.js", () => " " + external(otherId), { debugId: fileId }], + + ["other-external.js", () => external(), { debugId: otherId }, fileId], + ["other-linked.js", name => external() + url(name), { debugId: otherId }, fileId], + ["required-other-external.cjs", () => external(), { debugId: otherId }, fileId], + ["required-other-linked.cjs", name => external() + url(name), { debugId: otherId }, fileId], + ["other-tool.js", name => external("abc_def") + url(name), { debugId: otherId }, "abc_def"], + // Only a UUID, which is 32 hex digits, is the same id in another case or with dashes. + ["other-case.js", () => external("abc_def"), { debugId: "ABC_DEF" }, "abc_def"], + ["other-dashes.js", () => external("abc-def"), { debugId: "abcdef" }, "abc-def"], + [ + "not-hex.js", + () => external(fileId.replace("0", "g")), + { debugId: fileId.replace("0", "G") }, + fileId.replace("0", "g"), + ], + ["crlf-external.js", () => external(), { debugId: otherId }, fileId], + ["crlf-linked.js", name => external() + url(name), { debugId: otherId }, fileId], + ["no-final-line-end.js", () => external().trimEnd(), { debugId: otherId }, fileId], + // A `#!` line can be above the `// @bun` line. + ["hashbang-external.js", () => external(), { debugId: otherId }, fileId], + ["blank-before-other-id.js", () => external(" " + fileId), { debugId: otherId }, fileId], + ["space-after-id.js", () => external(fileId + " \t"), { debugId: otherId }, fileId], + ["space-after-id-linked.js", name => external(fileId + " \t") + url(name), { debugId: otherId }, fileId], + ["blank-line-above-the-url.js", name => external() + "\n" + url(name), { debugId: otherId }, fileId], + // sentry-cli writes the id there. + ["id-below-the-url.js", name => url(name) + "\n" + external(), { debugId: otherId }, fileId], + ["long-id-other-end.js", () => external(longId + "1"), { debugId: longId + "2" }, longId + "1"], + // Characters that are not ASCII above the id line do not hide it, below and above U+00FF. + [ + "not-ascii-above-id.js", + () => "/*! Copyright \u00a9 W\u00e4rting \u0100 */\n" + external(), + { debugId: otherId }, + fileId, + ], + // The id line is read at any distance from the end of the file. + [ + "long-url.js", + () => external() + `//# sourceMappingURL=https://example.com/${Buffer.alloc(100_000, "a")}.map\n`, + { debugId: otherId }, + fileId, + ], + ]; + const files: Record = {}; + for (const [name, tail, mapFields] of rows) { + const text = (name.endsWith(".cjs") ? cjsThrowsOnLine3 : throwsOnLine3) + tail(name); + files[name] = name.startsWith("crlf-") + ? text.replaceAll("\n", "\r\n") + : name.startsWith("hashbang-") + ? "#!/usr/bin/env bun\n" + text + : text; + files[name + ".map"] = map(mapFields); + } + // Each error is printed three times: its stack, then twice with the lines of code around it. The line on stderr + // shows that the whole warning is there before the program continues. + const printFrames = ` + ${frameOf} + for (const file of process.argv.slice(2)) { + const { t } = file.endsWith(".cjs") ? require("./" + file) : await import("./" + file); + try { + t(); + } catch (e) { + console.log(file + ": " + [frame(e.stack), frame(Bun.inspect(e)), frame(Bun.inspect(e))].join(" | ")); + } + console.error(file + ": done"); + } + `; + + const { stdout, stderr, exitCode } = await run(files, printFrames, ["main.js", ...rows.map(([name]) => name)]); + const expected = rows.map(([name, , mapFields, idOfFile]) => { + const line = name.startsWith("hashbang-") ? 4 : 3; + const frame = idOfFile === undefined ? mapped : `at t (/${name}:${line}:18)`; + const warning = idOfFile === undefined ? "" : refused(name, idOfFile, (mapFields as { debugId: string }).debugId); + return { stdout: `${name}: ${frame} | ${frame} | ${frame}\n`, stderr: `${warning}${name}: done\n` }; + }); + expect({ stdout: stdout.split(/(?<=\n)/), stderr: stderr.split(/(?<=done\n)/) }).toEqual({ + stdout: expected.map(row => row.stdout), + stderr: expected.map(row => row.stderr), + }); + expect(exitCode).toBe(0); + }); + + // The loader changes the text of a CommonJS file for a replaced `Module.wrapper`: it swaps the first line and cuts + // the last 4 characters, which are the end of the id here. The id of that text is not compared. + test.each([ + ["", ""], + ["", "\n"], + ["/*\u0100*/", "\n"], + ])("the map of a CommonJS bundle is read after Module.wrapper is replaced with %j and %j", async (...wrapper) => { + const files = { + "own.cjs": cjsThrowsOnLine3 + external(), + "own.cjs.map": map({ debugId: fileId }), + "other.js": throwsOnLine3 + external(), + "other.js.map": map({ debugId: otherId }), + }; + const script = ` + ${frameOf} + require("node:module").wrapper = JSON.parse(process.argv[2]); + for (const load of [() => require("./own.cjs"), () => import("./other.js")]) { + try { + (await load()).t(); + } catch (e) { + console.log(frame(e.stack)); + } + } + `; + + const { stdout, stderr, exitCode } = await run(files, script, ["main.js", JSON.stringify(wrapper)]); + expect({ stdout, stderr }).toEqual({ + stdout: `${mapped}\nat t (/other.js:3:18)\n`, + stderr: refused("other.js"), + }); + expect(exitCode).toBe(0); + }); + + test("an error that nothing catches has the warning and not the notes about a missing map", async () => { + const files = { + "external.js": throwsOnLine3 + "t();\n" + external(), + "external.js.map": map({ debugId: otherId }), + }; + + const { stdout, stderr, exitCode } = await run(files, "", ["external.js"]); + expect(stdout).toBe(""); + expect(stderr).toStartWith(refused("external.js")); + expect(stderr).toContain("at t (/external.js:3:9)"); + expect(stderr).not.toContain("missing sourcemaps"); + expect(exitCode).toBe(1); + }); + + test("a refused map is not read again for that load of the file, and the next load reads the map", async () => { + const script = ` + import { writeFileSync } from "node:fs"; + ${frameOf} + async function frameOfLoad(query) { + const { t } = await import("./reloaded.js" + query); + try { + t(); + } catch (e) { + return frame(e.stack); + } + } + console.log(await frameOfLoad("")); + writeFileSync("reloaded.js.map", process.argv[2]); + console.log(await frameOfLoad("")); + console.log(await frameOfLoad("?again")); + `; + const files = { + "reloaded.js": throwsOnLine3 + external(), + "reloaded.js.map": map({ debugId: otherId }), + }; + + const { stdout, stderr, exitCode } = await run(files, script, ["main.js", map({ debugId: fileId })]); + const unmapped = "at t (/reloaded.js:3:18)"; + expect({ stdout, stderr }).toEqual({ + stdout: `${unmapped}\n${unmapped}\n${mapped}\n`, + stderr: refused("reloaded.js"), + }); + expect(exitCode).toBe(0); + }); + + // The lines of code of an error are read again each time the error is printed: from the map, or else from the source + // file that the map names. ";;ECIE" maps generated 3:3 to original 5:3 of source 1. + test.each([ + ["and the source file is not there", {}, "5 | FIRST 5 | at t (/mapped.ts:5:3)\nat t (/mapped.ts:5:3)\n"], + [ + "and the source file is there", + { "mapped.ts": [1, 2, 3, 4, 5, 6].map(line => `FIRST ${line}\n`).join("") }, + "5 | FIRST 5 | at t (/mapped.ts:5:3)\n5 | FIRST 5 | at t (/mapped.ts:5:3)\n", + ], + ])( + "the lines of code do not come from a map that another build wrote while the file runs, %s", + async (_, source, stdoutOfBoth) => { + const code = (word: string) => [1, 2, 3, 4, 5, 6].map(line => `${word} ${line}\n`).join(""); + const script = ` + import { writeFileSync } from "node:fs"; + import { t } from "./replaced.js"; + function codeOfError() { + try { + t(); + } catch (e) { + const text = Bun.inspect(e); + return text.split("\\n").filter(line => / (FIRST|ADDED|OTHER) 5$| at t /.test(line)).map(line => line.trim()).join(" | "); + } + } + console.log(codeOfError()); + writeFileSync("replaced.js.map", process.argv[2]); + console.log(codeOfError()); + `; + const files = { + ...source, + "replaced.js": throwsOnLine3 + external(), + "replaced.js.map": map({ + debugId: fileId, + mappings: ";;ECIE", + sources: ["other.ts", "mapped.ts"], + sourcesContent: [code("OTHER"), code("FIRST")], + }), + }; + // The other build has one more source, so the source of the frame has another index in its map. + const ofAnotherBuild = map({ + debugId: otherId, + mappings: ";;ECIE", + sources: ["added.ts", "other.ts", "mapped.ts"], + sourcesContent: [code("ADDED"), code("OTHER"), code("FIRST")], + }); + + const { stdout, stderr, exitCode } = await run(files, script, ["main.js", ofAnotherBuild]); + expect({ stdout, stderr }).toEqual({ stdout: stdoutOfBoth, stderr: "" }); + expect(exitCode).toBe(0); + }, + ); +});