Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 30 additions & 36 deletions test/bundler/expectBundled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ export type MappingSnapshot = [
// If column is quoted text, find the token and use the column of it
// "index.ts:5:'abc'"
source_code: string,
// The generated position the source map maps that to, plus the text the
// generated file must contain there. Line is 1-based, column is 0-based:
// "3:2:return"
generated_mapping: string,
];

Expand Down Expand Up @@ -463,7 +466,7 @@ function testRef(id: string, options: BundlerTestInput): BundlerTestRef {
return { id, options };
}

function expectBundled(
export function expectBundled(
id: string,
opts: BundlerTestInput,
dryRun = false,
Expand Down Expand Up @@ -1651,8 +1654,8 @@ for (const [key, blob] of build.outputs) {

const loc_key = `${m.generatedLine}:${m.generatedColumn}`;
if (mappedLocations.has(loc_key)) {
const fmtLoc = (loc: any) =>
`${loc.generatedLine}:${m.generatedColumn} -> ${m.originalLine}:${m.originalColumn} [${m.source.replaceAll(/^(\.\.\/)+/g, "/").replace(root, "")}]`;
const fmtLoc = (loc: typeof m) =>
`${loc.generatedLine}:${loc.generatedColumn} -> ${loc.originalLine}:${loc.originalColumn} [${loc.source.replaceAll(/^(\.\.\/)+/g, "/").replace(root, "")}]`;

const a = fmtLoc(mappedLocations.get(loc_key));
const b = fmtLoc(m);
Expand All @@ -1666,30 +1669,31 @@ for (const [key, blob] of build.outputs) {
const map_tests = snapshotSourceMap?.[path.basename(file)];
if (map_tests) {
expect(parsed.sources.map((a: string) => a.replaceAll("\\", "/"))).toEqual(map_tests.files);
for (let i = 0; i < parsed.sources; i++) {
const map_dir = path.dirname(path.join(outdir!, file));
for (let i = 0; i < parsed.sources.length; i++) {
const source = parsed.sources[i];
const sourcemap_content = parsed.sourceContent[i];
const actual_content = readFileSync(path.resolve(path.join(outdir!, file), source), "utf-8");
expect(sourcemap_content).toBe(actual_content);
const actual_content = readFileSync(path.resolve(map_dir, source), "utf-8");
expect(parsed.sourcesContent[i], `${file}: sourcesContent of ${source}`).toBe(actual_content);
}

const generated_code = await Bun.file(path.join(outdir!, file.replace(".map", ""))).text();
const generated_lines = (await Bun.file(path.join(outdir!, file.replace(".map", ""))).text()).split("\n");

if (map_tests.mappings)
for (const mapping of map_tests.mappings) {
const src = parseSourceMapStrSource(outdir!, parsed, mapping[0]);
const dest = parseSourceMapStrGenerated(generated_code, mapping[1]);
for (const [source_str, generated_str] of map_tests.mappings) {
const src = parseSourceMapStrSource(outdir!, parsed, source_str);
const expected_text = parseSourceMapStrGenerated(generated_str);
const pos = map.generatedPositionFor(src);
if (!dest.matched) {
const real_generated = generated_code
.split("\n")
[pos.line! - 1].slice(pos.column!)
.slice(0, dest.expected!.length);
expect(`${pos.line}:${pos.column}:${real_generated}`).toBe(mapping[1]);
throw new Error("Not matched");
// Format what the map says in the same "line:col:text" shape as the
// snapshot so a failure can be pasted back into the test.
let actual = "unmapped";
if (pos.line !== null && pos.column !== null) {
const text = (generated_lines[pos.line - 1] ?? "").slice(
pos.column,
pos.column + expected_text.length,
);
actual = `${pos.line}:${pos.column}:${text}`;
}
expect(pos.line === dest.line);
expect(pos.column === dest.column);
expect(actual, `${file}: generated position of ${source_str}`).toBe(generated_str);
}
if (map_tests.mappingsExactMatch) {
expect(parsed.mappings).toBe(map_tests.mappingsExactMatch);
Expand Down Expand Up @@ -1998,36 +2002,26 @@ function parseSourceMapStrSource(root: string, source_map: SourceMap, string: st
return { line, column: col, source: source_map.sources[source_id] };
}

function parseSourceMapStrGenerated(source_code: string, string: string) {
/** Validates a "line:col:text" generated location and returns its `text` part. */
function parseSourceMapStrGenerated(string: string) {
const split = string.split(":");
if (split.length != 3)
throw new Error("Test is invalid; Invalid generated location. See MappingSnapshot typedef for more info.");
const [line_raw, col_raw, ...match] = split;
const line = Number(line_raw);
if (!Number.isInteger(line))
const [line_raw, col_raw, text] = split;
if (!Number.isInteger(Number(line_raw)))
throw new Error(
"Test is invalid; Invalid generated line " +
JSON.stringify(line_raw) +
". See MappingSnapshot typedef for more info.",
);

let column = Number(col_raw);
if (!Number.isInteger(column)) {
if (!Number.isInteger(Number(col_raw))) {
throw new Error(
"Test is invalid; Invalid generated column " +
JSON.stringify(col_raw) +
". See MappingSnapshot typedef for more info.",
);
}

if (match.length > 0) {
let str = match.join(":");
const text = source_code.split("\n")[line - 1];
const actual = text.slice(column, column + str.length);
if (actual !== str) {
return { matched: false, line, column, actual, expected: str };
}
}

return { matched: true, line, column };
return text;
}
116 changes: 116 additions & 0 deletions test/bundler/itBundled-snapshotSourceMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test";
import { isWindows } from "harness";
import { BundlerTestInput, expectBundled, itBundled, MappingSnapshot } from "./expectBundled";

// Every case in this file bundles the same two files. out/entry.js is:
//
// 1 | // greet.ts
// 2 | function greet(name) {
// 3 | return "hello " + name;
// 4 | }
// 5 |
// 6 | // entry.ts
// 7 | console.log(greet("world"));
// 8 | console.log(greet("bun"));
const bundle = {
files: {
"/entry.ts": /* ts */ `
import { greet } from "./greet";
console.log(greet("world"));
console.log(greet("bun"));
`,
"/greet.ts": /* ts */ `
// greets someone
export function greet(name: string) {
return "hello " + name;
}
`,
},
outdir: "/out",
sourceMap: "external",
} satisfies BundlerTestInput;
const files = ["../greet.ts", "../entry.ts"];

function withMapping(mapping: MappingSnapshot): Partial<BundlerTestInput> {
return { snapshotSourceMap: { "entry.js.map": { files, mappings: [mapping] } } };
}

// Source map checks that have to fail, and how each failure has to start. The
// quoted text does exist at each claimed generated position, which used to be
// the only thing `mappings` checked.
const rejected: Record<string, { opts: Partial<BundlerTestInput>; error: string }> = {
// Generated line 8 also starts with `console`, but entry.ts:2 maps to line 7.
WrongLine: {
opts: withMapping(["entry.ts:2:'console'", "8:0:console"]),
error: `entry.js.map: generated position of entry.ts:2:'console'\n\nExpected: "8:0:console"\nReceived: "7:0:console"`,
},
WrongText: {
opts: withMapping(["entry.ts:2:'console'", "7:0:nope"]),
error: `entry.js.map: generated position of entry.ts:2:'console'\n\nExpected: "7:0:nope"\nReceived: "7:0:cons"`,
},
// Generated line 1 is the `// greet.ts` comment; nothing in greet.ts:1 (also
// a comment) is mapped at all.
Unmapped: {
opts: withMapping(["greet.ts:1:'greets'", "1:3:greet"]),
error: `entry.js.map: generated position of greet.ts:1:'greets'\n\nExpected: "1:3:greet"\nReceived: "unmapped"`,
},
// runtimeFiles are written after bundling, so the map's sourcesContent no
// longer matches greet.ts on disk.
StaleSourcesContent: {
opts: {
snapshotSourceMap: { "entry.js.map": { files } },
runtimeFiles: { "/greet.ts": `export function greet() {}` },
},
error: "entry.js.map: sourcesContent of ../greet.ts\n",
},
// Every external source map is checked for one generated position that maps
// to two source positions. "AACA" adds a segment at the column of the last
// segment on generated line 8 that points one source line further down.
DuplicateMapping: {
opts: {
onAfterBundle(api) {
const map = JSON.parse(api.readFile("out/entry.js.map"));
map.mappings = map.mappings.replace(/;*$/, ",AACA");
api.writeFile("out/entry.js.map", JSON.stringify(map));
},
},
error: "Duplicate mapping in source-map for 8:24\n8:24 -> 3:24 [/entry.ts]\n8:24 -> 4:24 [/entry.ts]",
},
};

describe("bundler", () => {
itBundled("harness/SnapshotSourceMap", {
...bundle,
snapshotSourceMap: {
"entry.js.map": {
files,
mappings: [
["greet.ts:2:'greet'", "2:9:greet"],
["greet.ts:3:'return'", "3:2:return"],
["entry.ts:2:'console'", "7:0:console"],
["entry.ts:3:'\"bun\"'", '8:18:"bun"'],
],
},
},
});

// expectBundled's "test/bundler/" stack check never matches backslash paths, so
// it throws before bundling anything on Windows (the itBundled case above is
// silently dropped there for the same reason).
describe.skipIf(isWindows)("rejects", () => {
for (const [name, { opts, error }] of Object.entries(rejected)) {
const id = `harness/SnapshotSourceMap${name}`;
test(id, async () => {
let message = "<expectBundled() passed>";
try {
// ignoreFilter: an ambient BUN_BUNDLER_TEST_FILTER must not turn these into no-ops.
await expectBundled(id, { ...bundle, ...opts }, false, true);
} catch (e: any) {
// Expected/Received are colored when running in a terminal.
message = Bun.stripANSI(e.message);
}
expect(message).toStartWith(error);
});
}
});
});