1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
|
# `bun build` tests using `expectBundled`
Most bundler tests were ported [from esbuild][1], located in `test/bundler/esbuild`. Our own tests are in `bundler_*.test.ts`
[1]: https://github.com/evanw/esbuild/tree/main/internal/bundler_tests
Call `expectBundled` within a test to test the bundler. The `id` passed as the first argument must be unique across the all tests, and generally uses the format `file/TestName`. The second parameter is an options object.
All bundle entry files, their outputs, and other helpful files are written to disk at: `$TEMP/bun-build-tests/{run_id}/{id}`. This can be used to inspect and debug bundles, as they are not deleted after runtime.
In addition to comparing the bundle outputs against snapshots, **most test cases execute the bundle and have additional checks to assert the intended logic is happening properly**. This allows the bundler to change exactly how it writes files (optimizations / variable renaming), and still have concrete tests that ensure what the bundler creates will function properly. Snapshots are also taken, but these are used to check for regressions and not neccessarily check accuracy.
On top of `expectBundled`, there is also `itBundled` which wraps `expectBundled` and `it` together, which is what we mostly use in our tests.
These two functions have many options you can pass to it, check the examples below for some common use cases, then look at the `BundlerTestInput` for a complete set of options. Not all of the options are implemented; these tests get auto-skipped.
## Running tests
You can use `bun test` as normal, but `expectBundled` looks for these environment variables:
- `BUN_BUNDLER_TEST_USE_ESBUILD` - Use `esbuild` instead of `bun build`.
- `BUN_BUNDLER_TEST_DEBUG` - Write extra files to disk and log extra info.
- `BUN_BUNDLER_TEST_FILTER` - Set this to the exact id of a test to only run that test.
- `BUN_EXE` - Override the path to the `bun` executable.
There is also a helper CLI that sets these variables:
```sh
$ ./run-single-bundler-test.sh default/ExportMissingES6
$ ./run-single-bundler-test.sh default/ExportMissingES6 e
```
Passing the second argument at all will use `esbuild` instead of `bun build`. It also creates a symlink `./out` to the output directory, for faster inspection. I have this aliased to `tb` (test bun) in my shell for fast usage.
## Basic Examples and Common Patterns
At the start of test files, use `testForFile` instead of importing from `bun:test`:
```ts
import { bundlerTest, expectBundled, itBundled, testForFile } from "./expectBundled";
var { describe, test, expect } = testForFile(import.meta.path);
```
Basic example (this goes in a `describe`)
```ts
itBundled("default/SimpleES6", {
files: {
// Define one or more files. Strings are passed through `dedent`
// First file is the entry file
"/entry.js": /* js */ `
import { fn } from './foo';
console.log(fn());
`,
"/foo.js": /* js */ `
export function fn() {
return 123
}
`,
},
// outfile: "out.js", // Default is "out.js"
// Passing `run` will run the bundle
run: {
stdout: "123",
// file: "out.js", // Default is whatever `outfile` is
},
});
```
Testing the exports of a module using `runtimeFiles`:
```ts
itBundled("importstar/ExportSelfES6", {
files: {
"/entry.js": /* js */ `
export const foo = 123
export * from './entry'
`,
},
format: "esm",
// `runtimeFiles` are only available after the bundle is created, which lets you
// keep some files secret, like preventing externals from being bundled, etc.
// It can also be used to provide a runtime entry point.
runtimeFiles: {
"/test.js": /* js */ `
import * as foo from './out.js'
// Try avoiding relying on Bun's object formatter, instead use JSON.stringify when possible
// This will avoid any changes to how these objects are formatted.
console.log(JSON.stringify(foo));
`,
},
run: {
file: "/test.js",
// console.log is a great way to assert the proper values exist, but when needed you
// can also reach for `import "assert"` and run that in the test.
stdout: '{"foo":123}',
},
});
```
You can use a `test.js` to define extra variables via `globalThis`:
```ts
itBundled("default/MinifiedBundleEndingWithImportantSemicolon", {
files: {
// foo() is not defined in this scope
"/entry.js": `while(foo()); // This semicolon must not be stripped`,
"/test.js": /* js */ `
let i = 0;
// let's define foo()
globalThis.foo = () => {
console.log(i++);
return i === 1;
};
await import('./out.js')
`,
},
minifyWhitespace: true,
format: "iife",
run: {
file: "/test.js",
stdout: "0\n1",
},
});
```
## onAfterBundle
Since not every possible test case can be covered by `run` and the other options, you can pass a function `onAfterBundle` to add custom checks.
```ts
itBundled("default/ThisOutsideFunctionRenamedToExports", {
files: {
"/entry.js": /* js */ `
console.log(this)
console.log((x = this) => this)
console.log({x: this})
console.log(class extends this.foo {})
console.log(class { [this.foo] })
console.log(class { [this.foo]() {} })
console.log(class { static [this.foo] })
console.log(class { static [this.foo]() {} })
`,
},
onAfterBundle(api) {
if (api.readFile("/out.js").includes("this")) {
throw new Error("All cases of `this` should have been rewritten to `exports`");
}
},
});
```
Check the `BundlerTestBundleAPI` typedef for all available methods. Note that `api.readFile` is cached so you can call it multiple times without worrying about anything.
This callback is run before `run`, so you can use tricks like `appendFile` to add extra data, useful when testing IIFE bundles in combination with `globalName`
```ts
itBundled("importstar/ReExportStarExternalIIFE", {
files: {
"/entry.js": `export * from "foo"`,
},
format: "iife",
globalName: "mod",
external: ["foo"],
runtimeFiles: {
"/node_modules/foo/index.js": /* js */ `
export const foo = 'foo'
export const bar = 'bar'
`,
},
onAfterBundle(api) {
api.appendFile("/out.js", "\nconsole.log(JSON.stringify(mod))");
},
run: {
stdout: '{"bar":"bar","foo":"foo"}',
},
});
```
## dce: true
This parameter checks the bundle for strings like `DROP`, `REMOVE`, and `FAIL` within the bundle, and will throw an error. This is handy for dead code elimination tests where you can just name variables that should be removed with one of those trigger words. In addition, `KEEP`, `PRESERVE`, and `KEEPME` is scanned in the source code and will throw an error if the count of those strings is not equal to the count of the corresponding trigger strings.
Places that are not required to be dce'd contain `POSSIBLE_REMOVAL` and do not trigger an error if not removed. These might be able to be optimized in the future.
## keepNames tricks
In `esbuild/default.test.ts`, test `default/KeepNamesTreeShaking`, we call the esbuild cli to minify identifiers, and then check the code for expected class names to survive the minification (keep names forcibily sets functions `.name`).
|